mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-08 22:24:09 +00:00
Merge pull request #182 from kaitranntt/dev
fix(ui): consistent CLI indicators and enhanced binary downloader
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.5.0",
|
||||
"version": "7.5.0-dev.2",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
+7
-6
@@ -12,6 +12,7 @@ import {
|
||||
getWebSearchHookEnv,
|
||||
} from './utils/websearch-manager';
|
||||
import { getGlobalEnvConfig } from './config/unified-config-loader';
|
||||
import { fail, info } from './utils/ui';
|
||||
|
||||
// Import centralized error handling
|
||||
import { handleError, runCleanup } from './errors';
|
||||
@@ -75,7 +76,7 @@ async function execClaudeWithProxy(
|
||||
const apiKey = envData['ANTHROPIC_AUTH_TOKEN'];
|
||||
|
||||
if (!apiKey || apiKey === 'YOUR_GLM_API_KEY_HERE') {
|
||||
console.error('[X] GLMT profile requires Z.AI API key');
|
||||
console.error(fail('GLMT profile requires Z.AI API key'));
|
||||
console.error(' Edit ~/.ccs/glmt.settings.json and set ANTHROPIC_AUTH_TOKEN');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -134,7 +135,7 @@ async function execClaudeWithProxy(
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
spinner.fail('Failed to start GLMT proxy');
|
||||
console.error('[X] Error:', err.message);
|
||||
console.error(fail(`Error: ${err.message}`));
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(' 1. Port conflict (unlikely with random port)');
|
||||
@@ -192,7 +193,7 @@ async function execClaudeWithProxy(
|
||||
});
|
||||
|
||||
claude.on('error', (error) => {
|
||||
console.error('[X] Claude CLI error:', error);
|
||||
console.error(fail(`Claude CLI error: ${error}`));
|
||||
proxy.kill('SIGTERM');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -475,7 +476,7 @@ async function main(): Promise<void> {
|
||||
const { executeCopilotProfile } = await import('./copilot');
|
||||
const copilotConfig = profileInfo.copilotConfig;
|
||||
if (!copilotConfig) {
|
||||
console.error('[X] Copilot configuration not found');
|
||||
console.error(fail('Copilot configuration not found'));
|
||||
process.exit(1);
|
||||
}
|
||||
const exitCode = await executeCopilotProfile(copilotConfig, remainingArgs);
|
||||
@@ -505,7 +506,7 @@ async function main(): Promise<void> {
|
||||
// Log global env injection for visibility (debug mode only)
|
||||
if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) {
|
||||
const envNames = Object.keys(globalEnv).join(', ');
|
||||
console.error(`[i] Global env: ${envNames}`);
|
||||
console.error(info(`Global env: ${envNames}`));
|
||||
}
|
||||
|
||||
// CRITICAL: Load settings and explicitly set ANTHROPIC_* env vars
|
||||
@@ -563,7 +564,7 @@ async function main(): Promise<void> {
|
||||
const allProfiles = err.availableProfiles.split('\n');
|
||||
await ErrorManager.showProfileNotFound(err.profileName, allProfiles, err.suggestions);
|
||||
} else {
|
||||
console.error(`[X] ${err.message}`);
|
||||
console.error(fail(err.message));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Binary Downloader
|
||||
* Handles downloading files with retry logic, progress tracking, and redirect following.
|
||||
* Robust handling for transient network errors (socket hang up, ECONNRESET, etc.)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
@@ -14,40 +15,111 @@ export interface DownloaderConfig {
|
||||
maxRetries: number;
|
||||
/** Enable verbose logging */
|
||||
verbose: boolean;
|
||||
/** Timeout in milliseconds (default: 120000 for large files) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: DownloaderConfig = {
|
||||
maxRetries: 3,
|
||||
maxRetries: 5,
|
||||
verbose: false,
|
||||
timeout: 120000, // 2 minutes for large binaries
|
||||
};
|
||||
|
||||
/** Error types for categorized handling */
|
||||
export type NetworkErrorType = 'socket' | 'timeout' | 'http' | 'redirect' | 'unknown';
|
||||
|
||||
/** Categorize error for appropriate retry/reporting */
|
||||
export function categorizeError(error: Error): NetworkErrorType {
|
||||
const msg = error.message.toLowerCase();
|
||||
if (msg.includes('socket hang up') || msg.includes('econnreset') || msg.includes('epipe')) {
|
||||
return 'socket';
|
||||
}
|
||||
if (msg.includes('timeout') || msg.includes('etimedout')) {
|
||||
return 'timeout';
|
||||
}
|
||||
if (msg.includes('http ')) {
|
||||
return 'http';
|
||||
}
|
||||
if (msg.includes('redirect')) {
|
||||
return 'redirect';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Get user-friendly error message */
|
||||
export function getErrorMessage(error: Error, attempt: number, maxAttempts: number): string {
|
||||
const type = categorizeError(error);
|
||||
const prefix = `[Attempt ${attempt}/${maxAttempts}]`;
|
||||
|
||||
switch (type) {
|
||||
case 'socket':
|
||||
return `${prefix} Connection dropped (socket hang up) - retrying with fresh connection...`;
|
||||
case 'timeout':
|
||||
return `${prefix} Download timed out - retrying with extended timeout...`;
|
||||
case 'http':
|
||||
return `${prefix} Server error: ${error.message}`;
|
||||
case 'redirect':
|
||||
return `${prefix} Redirect failed: ${error.message}`;
|
||||
default:
|
||||
return `${prefix} Network error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if error is retryable */
|
||||
export function isRetryableError(error: Error): boolean {
|
||||
const type = categorizeError(error);
|
||||
// Retry socket errors, timeouts, and unknown errors
|
||||
if (type === 'socket' || type === 'timeout' || type === 'unknown') {
|
||||
return true;
|
||||
}
|
||||
if (type === 'http') {
|
||||
const msg = error.message;
|
||||
// Retry 5xx server errors and 429 rate limit (HTTP 5xx matches 500, 502, 503, 504, etc.)
|
||||
return msg.includes('HTTP 5') || msg.includes('HTTP 429');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file from URL with progress tracking
|
||||
* @param timeout Timeout in ms (default 120000 for large files)
|
||||
*/
|
||||
export function downloadFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
onProgress?: ProgressCallback,
|
||||
verbose = false
|
||||
verbose = false,
|
||||
timeout = 120000
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = (err?: Error) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
fs.unlink(destPath, () => {}); // Cleanup partial file
|
||||
reject(err || new Error('Download aborted'));
|
||||
};
|
||||
|
||||
const handleResponse = (res: http.IncomingMessage) => {
|
||||
// Handle redirects (GitHub releases use 302)
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
const redirectUrl = res.headers.location;
|
||||
if (!redirectUrl) {
|
||||
reject(new Error('Redirect without location header'));
|
||||
cleanup(new Error('Redirect without location header'));
|
||||
return;
|
||||
}
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] Following redirect: ${redirectUrl}`);
|
||||
}
|
||||
downloadFile(redirectUrl, destPath, onProgress, verbose).then(resolve).catch(reject);
|
||||
downloadFile(redirectUrl, destPath, onProgress, verbose, timeout)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
||||
cleanup(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,58 +142,94 @@ export function downloadFile(
|
||||
res.pipe(fileStream);
|
||||
|
||||
fileStream.on('finish', () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
fileStream.close();
|
||||
resolve();
|
||||
});
|
||||
|
||||
fileStream.on('error', (err) => {
|
||||
fs.unlink(destPath, () => {}); // Cleanup partial file
|
||||
reject(err);
|
||||
});
|
||||
|
||||
res.on('error', (err) => {
|
||||
fs.unlink(destPath, () => {});
|
||||
reject(err);
|
||||
});
|
||||
fileStream.on('error', cleanup);
|
||||
res.on('error', cleanup);
|
||||
};
|
||||
|
||||
const protocol = url.startsWith('https') ? https : http;
|
||||
const req = protocol.get(url, handleResponse);
|
||||
|
||||
req.on('error', reject);
|
||||
req.setTimeout(60000, () => {
|
||||
// Use agent: false to prevent connection pooling (allows process to exit)
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'CCS-CLIProxyPlus-Downloader/1.0',
|
||||
},
|
||||
agent: false, // Disable connection pooling for clean exit
|
||||
};
|
||||
|
||||
const req = protocol.get(url, options, handleResponse);
|
||||
|
||||
req.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
cleanup(err);
|
||||
}
|
||||
});
|
||||
|
||||
req.setTimeout(timeout, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Download timeout (60s)'));
|
||||
if (!resolved) {
|
||||
cleanup(new Error(`Download timeout (${timeout / 1000}s)`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file with retry logic and exponential backoff
|
||||
* Uses smarter backoff for socket errors (longer delays)
|
||||
*/
|
||||
export async function downloadWithRetry(
|
||||
url: string,
|
||||
destPath: string,
|
||||
config: Partial<DownloaderConfig> = {}
|
||||
): Promise<DownloadResult> {
|
||||
const { maxRetries, verbose } = { ...DEFAULT_CONFIG, ...config };
|
||||
let lastError = '';
|
||||
const { maxRetries, verbose, timeout } = { ...DEFAULT_CONFIG, ...config };
|
||||
let lastError: Error | null = null;
|
||||
let retries = 0;
|
||||
let currentTimeout = timeout || 120000;
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
await downloadFile(url, destPath, undefined, verbose);
|
||||
await downloadFile(url, destPath, undefined, verbose, currentTimeout);
|
||||
return { success: true, filePath: destPath, retries };
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
lastError = err.message;
|
||||
lastError = err;
|
||||
retries++;
|
||||
|
||||
if (retries < maxRetries) {
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
const delay = Math.pow(2, retries - 1) * 1000;
|
||||
// Check if error is retryable
|
||||
if (!isRetryableError(err)) {
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] Retry ${retries}/${maxRetries} after ${delay}ms: ${lastError}`);
|
||||
console.error(`[cliproxy] Non-retryable error: ${err.message}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (retries < maxRetries) {
|
||||
const errorType = categorizeError(err);
|
||||
// Socket errors: longer backoff (2s, 4s, 8s, 16s, 32s)
|
||||
// Timeout errors: increase timeout and use standard backoff
|
||||
// Other errors: standard exponential backoff (1s, 2s, 4s...)
|
||||
let delay: number;
|
||||
|
||||
if (errorType === 'socket') {
|
||||
delay = Math.pow(2, retries) * 1000; // 2s, 4s, 8s, 16s, 32s
|
||||
} else if (errorType === 'timeout') {
|
||||
delay = Math.pow(2, retries - 1) * 1000;
|
||||
currentTimeout = Math.min(currentTimeout * 1.5, 300000); // Increase timeout up to 5 min
|
||||
} else {
|
||||
delay = Math.pow(2, retries - 1) * 1000;
|
||||
}
|
||||
|
||||
// Log with user-friendly message
|
||||
console.error(`[cliproxy] ${getErrorMessage(err, retries, maxRetries)}`);
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] Waiting ${delay}ms before retry...`);
|
||||
}
|
||||
await sleep(delay);
|
||||
}
|
||||
@@ -130,16 +238,18 @@ export async function downloadWithRetry(
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Download failed after ${retries} attempts: ${lastError}`,
|
||||
error: `Download failed after ${retries} attempts: ${lastError?.message || 'Unknown error'}`,
|
||||
retries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch text content from URL
|
||||
* Fetch text content from URL (single attempt)
|
||||
*/
|
||||
export function fetchText(url: string, verbose = false): Promise<string> {
|
||||
function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
|
||||
const handleResponse = (res: http.IncomingMessage) => {
|
||||
// Handle redirects
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
@@ -148,7 +258,7 @@ export function fetchText(url: string, verbose = false): Promise<string> {
|
||||
reject(new Error('Redirect without location header'));
|
||||
return;
|
||||
}
|
||||
fetchText(redirectUrl, verbose).then(resolve).catch(reject);
|
||||
fetchTextOnce(redirectUrl, verbose, timeout).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,30 +269,90 @@ export function fetchText(url: string, verbose = false): Promise<string> {
|
||||
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => resolve(data));
|
||||
res.on('error', reject);
|
||||
res.on('end', () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
res.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const protocol = url.startsWith('https') ? https : http;
|
||||
const req = protocol.get(url, handleResponse);
|
||||
req.on('error', reject);
|
||||
req.setTimeout(30000, () => {
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'CCS-CLIProxyPlus-Downloader/1.0',
|
||||
},
|
||||
agent: false, // Disable connection pooling for clean exit
|
||||
};
|
||||
|
||||
const req = protocol.get(url, options, handleResponse);
|
||||
req.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
req.setTimeout(timeout, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout (30s)'));
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`Request timeout (${timeout / 1000}s)`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from URL (for GitHub API)
|
||||
* Fetch text content from URL with retry logic
|
||||
*/
|
||||
export function fetchJson(url: string, verbose = false): Promise<Record<string, unknown>> {
|
||||
export async function fetchText(url: string, verbose = false, maxRetries = 3): Promise<string> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fetchTextOnce(url, verbose);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
lastError = err;
|
||||
|
||||
if (!isRetryableError(err) || attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = Math.pow(2, attempt - 1) * 1000;
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] fetchText retry ${attempt}/${maxRetries}: ${err.message}`);
|
||||
}
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('fetchText failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from URL (single attempt, for GitHub API)
|
||||
*/
|
||||
function fetchJsonOnce(
|
||||
url: string,
|
||||
verbose = false,
|
||||
timeout = 15000
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
let resolved = false;
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
headers: {
|
||||
'User-Agent': 'CCS-CLIProxyPlus-Updater/1.0',
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
agent: false, // Disable connection pooling for clean exit
|
||||
};
|
||||
|
||||
const handleResponse = (res: http.IncomingMessage) => {
|
||||
@@ -192,7 +362,7 @@ export function fetchJson(url: string, verbose = false): Promise<Record<string,
|
||||
reject(new Error('Redirect without location header'));
|
||||
return;
|
||||
}
|
||||
fetchJson(redirectUrl, verbose).then(resolve).catch(reject);
|
||||
fetchJsonOnce(redirectUrl, verbose, timeout).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -204,24 +374,71 @@ export function fetchJson(url: string, verbose = false): Promise<Record<string,
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
reject(new Error('Invalid JSON from GitHub API'));
|
||||
}
|
||||
});
|
||||
res.on('error', reject);
|
||||
res.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const req = https.get(url, options, handleResponse);
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => {
|
||||
req.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
req.setTimeout(timeout, () => {
|
||||
req.destroy();
|
||||
reject(new Error('GitHub API timeout (10s)'));
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`GitHub API timeout (${timeout / 1000}s)`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from URL (for GitHub API) with retry logic
|
||||
*/
|
||||
export async function fetchJson(
|
||||
url: string,
|
||||
verbose = false,
|
||||
maxRetries = 3
|
||||
): Promise<Record<string, unknown>> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fetchJsonOnce(url, verbose);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
lastError = err;
|
||||
|
||||
if (!isRetryableError(err) || attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = Math.pow(2, attempt - 1) * 1000;
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] GitHub API retry ${attempt}/${maxRetries}: ${err.message}`);
|
||||
}
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('fetchJson failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep helper
|
||||
*/
|
||||
|
||||
@@ -424,13 +424,17 @@ export async function execClaudeWithCLIProxy(
|
||||
if (proxyStatus.running && proxyStatus.verified) {
|
||||
// Healthy proxy found - join it
|
||||
if (proxyStatus.pid) {
|
||||
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid) ?? undefined;
|
||||
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined;
|
||||
}
|
||||
if (sessionId) {
|
||||
console.log(info(`Joined existing CLIProxy on port ${cfg.port} (${proxyStatus.method})`));
|
||||
} else {
|
||||
// Failed to register session, but proxy is running - continue anyway
|
||||
log(`Failed to register session but proxy is healthy, continuing...`);
|
||||
// Failed to register session - proxy is running but we can't track it
|
||||
// This happens when port-process detection fails (permissions, platform issues)
|
||||
console.log(
|
||||
info(`Using existing CLIProxy on port ${cfg.port} (session tracking unavailable)`)
|
||||
);
|
||||
log(`PID=${proxyStatus.pid ?? 'unknown'}, session registration skipped`);
|
||||
}
|
||||
return; // Exit lock early, skip spawning
|
||||
}
|
||||
@@ -441,7 +445,7 @@ export async function execClaudeWithCLIProxy(
|
||||
const becameHealthy = await waitForProxyHealthy(cfg.port, cfg.timeout);
|
||||
if (becameHealthy) {
|
||||
if (proxyStatus.pid) {
|
||||
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid) ?? undefined;
|
||||
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined;
|
||||
}
|
||||
console.log(info(`Joined CLIProxy after startup wait`));
|
||||
return; // Exit lock early
|
||||
@@ -459,7 +463,7 @@ export async function execClaudeWithCLIProxy(
|
||||
// Last resort: try HTTP health check (handles Windows PID-XXXXX case)
|
||||
const isActuallyOurs = await waitForProxyHealthy(cfg.port, 1000);
|
||||
if (isActuallyOurs) {
|
||||
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.blocker.pid) ?? undefined;
|
||||
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.blocker.pid, verbose) ?? undefined;
|
||||
console.log(info(`Reclaimed CLIProxy with unrecognized process name`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,12 +73,25 @@ export async function detectRunningProxy(
|
||||
// Proxy is running and responsive
|
||||
// Try to get PID from session lock if available
|
||||
const lock = getExistingProxy(port);
|
||||
log(`HTTP check passed, proxy healthy (PID: ${lock?.pid ?? 'unknown'})`);
|
||||
let pid = lock?.pid;
|
||||
|
||||
// If no PID from session lock, try port-process detection
|
||||
// This handles orphaned proxies (running but no sessions.json)
|
||||
if (!pid) {
|
||||
log('No PID from session lock, checking port process...');
|
||||
const portProcess = await getPortProcess(port);
|
||||
if (portProcess) {
|
||||
pid = portProcess.pid;
|
||||
log(`Got PID from port process: ${pid}`);
|
||||
}
|
||||
}
|
||||
|
||||
log(`HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'})`);
|
||||
return {
|
||||
running: true,
|
||||
verified: true,
|
||||
method: 'http',
|
||||
pid: lock?.pid,
|
||||
pid,
|
||||
sessionCount: lock?.sessions?.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -616,7 +616,7 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
if (installIdx !== -1) {
|
||||
const version = args[installIdx + 1];
|
||||
if (!version || version.startsWith('-')) {
|
||||
console.error('[X] Missing version argument for --install');
|
||||
console.error(fail('Missing version argument for --install'));
|
||||
console.error(' Usage: ccs cliproxy --install <version>');
|
||||
console.error(' Example: ccs cliproxy --install 6.5.53');
|
||||
process.exit(1);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { startServer } from '../web-server';
|
||||
import { setupGracefulShutdown } from '../web-server/shutdown';
|
||||
import { ensureCliproxyService } from '../cliproxy/service-manager';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator';
|
||||
import { initUI, header, ok, info, warn } from '../utils/ui';
|
||||
import { initUI, header, ok, info, warn, fail } from '../utils/ui';
|
||||
|
||||
interface ConfigOptions {
|
||||
port?: number;
|
||||
@@ -33,7 +33,7 @@ function parseArgs(args: string[]): ConfigOptions {
|
||||
if (!isNaN(port) && port > 0 && port < 65536) {
|
||||
result.port = port;
|
||||
} else {
|
||||
console.error('[X] Invalid port number');
|
||||
console.error(fail('Invalid port number'));
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (arg === '--dev') {
|
||||
@@ -137,7 +137,7 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
|
||||
console.log('');
|
||||
console.log(info('Press Ctrl+C to stop'));
|
||||
} catch (error) {
|
||||
console.error('[X] Failed to start server:', (error as Error).message);
|
||||
console.error(fail(`Failed to start server: ${(error as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../copilot';
|
||||
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
|
||||
import { ok, fail, info, color } from '../utils/ui';
|
||||
|
||||
/**
|
||||
* Handle copilot subcommand.
|
||||
@@ -42,7 +43,7 @@ export async function handleCopilotCommand(args: string[]): Promise<number> {
|
||||
case '-h':
|
||||
return handleHelp();
|
||||
default:
|
||||
console.error(`[X] Unknown subcommand: ${subcommand}`);
|
||||
console.error(fail(`Unknown subcommand: ${subcommand}`));
|
||||
console.error('');
|
||||
return handleHelp();
|
||||
}
|
||||
@@ -81,7 +82,7 @@ function handleHelp(): number {
|
||||
*/
|
||||
async function handleAuth(): Promise<number> {
|
||||
if (!isCopilotApiInstalled()) {
|
||||
console.error('[X] copilot-api is not installed.');
|
||||
console.error(fail('copilot-api is not installed.'));
|
||||
console.error('');
|
||||
console.error('Install with: npm install -g copilot-api');
|
||||
return 1;
|
||||
@@ -91,7 +92,7 @@ async function handleAuth(): Promise<number> {
|
||||
|
||||
if (result.success) {
|
||||
console.log('');
|
||||
console.log('[OK] Authentication successful!');
|
||||
console.log(ok('Authentication successful!'));
|
||||
console.log('');
|
||||
console.log('Next steps:');
|
||||
console.log(' 1. Enable copilot: ccs copilot enable');
|
||||
@@ -100,7 +101,7 @@ async function handleAuth(): Promise<number> {
|
||||
return 0;
|
||||
} else {
|
||||
console.error('');
|
||||
console.error(`[X] ${result.error}`);
|
||||
console.error(fail(result.error || 'Authentication failed'));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -119,17 +120,17 @@ async function handleStatus(): Promise<number> {
|
||||
console.log('');
|
||||
|
||||
// Enabled status
|
||||
const enabledIcon = copilotConfig.enabled ? '[OK]' : '[X]';
|
||||
const enabledIcon = copilotConfig.enabled ? color('[OK]', 'success') : color('[X]', 'error');
|
||||
const enabledText = copilotConfig.enabled ? 'Enabled' : 'Disabled';
|
||||
console.log(`Integration: ${enabledIcon} ${enabledText}`);
|
||||
|
||||
// Auth status
|
||||
const authIcon = status.auth.authenticated ? '[OK]' : '[X]';
|
||||
const authIcon = status.auth.authenticated ? color('[OK]', 'success') : color('[X]', 'error');
|
||||
const authText = status.auth.authenticated ? 'Authenticated' : 'Not authenticated';
|
||||
console.log(`Authentication: ${authIcon} ${authText}`);
|
||||
|
||||
// Daemon status
|
||||
const daemonIcon = status.daemon.running ? '[OK]' : '[X]';
|
||||
const daemonIcon = status.daemon.running ? color('[OK]', 'success') : color('[X]', 'error');
|
||||
const daemonText = status.daemon.running ? 'Running' : 'Not running';
|
||||
console.log(`Daemon: ${daemonIcon} ${daemonText}`);
|
||||
|
||||
@@ -196,15 +197,15 @@ async function handleStart(): Promise<number> {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
|
||||
|
||||
console.log(`[i] Starting copilot-api daemon on port ${copilotConfig.port}...`);
|
||||
console.log(info(`Starting copilot-api daemon on port ${copilotConfig.port}...`));
|
||||
|
||||
const result = await startDaemon(copilotConfig);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[OK] Daemon started (PID: ${result.pid})`);
|
||||
console.log(ok(`Daemon started (PID: ${result.pid})`));
|
||||
return 0;
|
||||
} else {
|
||||
console.error(`[X] ${result.error}`);
|
||||
console.error(fail(result.error || 'Failed to start daemon'));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -213,15 +214,15 @@ async function handleStart(): Promise<number> {
|
||||
* Handle stop subcommand.
|
||||
*/
|
||||
async function handleStop(): Promise<number> {
|
||||
console.log('[i] Stopping copilot-api daemon...');
|
||||
console.log(info('Stopping copilot-api daemon...'));
|
||||
|
||||
const result = await stopDaemon();
|
||||
|
||||
if (result.success) {
|
||||
console.log('[OK] Daemon stopped');
|
||||
console.log(ok('Daemon stopped'));
|
||||
return 0;
|
||||
} else {
|
||||
console.error(`[X] ${result.error}`);
|
||||
console.error(fail(result.error || 'Failed to stop daemon'));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -239,7 +240,7 @@ async function handleEnable(): Promise<number> {
|
||||
config.copilot.enabled = true;
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
console.log('[OK] Copilot integration enabled');
|
||||
console.log(ok('Copilot integration enabled'));
|
||||
console.log('');
|
||||
console.log('Next steps:');
|
||||
console.log(' 1. Authenticate: ccs copilot auth');
|
||||
@@ -260,7 +261,7 @@ async function handleDisable(): Promise<number> {
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
|
||||
console.log('[OK] Copilot integration disabled');
|
||||
console.log(ok('Copilot integration disabled'));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth';
|
||||
import { isDaemonRunning, startDaemon } from './copilot-daemon';
|
||||
import { ensureCopilotApi } from './copilot-package-manager';
|
||||
import { CopilotStatus } from './types';
|
||||
import { fail, info, ok } from '../utils/ui';
|
||||
|
||||
/**
|
||||
* Get full copilot status (auth + daemon).
|
||||
@@ -73,7 +74,7 @@ export async function executeCopilotProfile(
|
||||
try {
|
||||
await ensureCopilotApi();
|
||||
} catch (error) {
|
||||
console.error('[X] Failed to install copilot-api.');
|
||||
console.error(fail('Failed to install copilot-api.'));
|
||||
console.error('');
|
||||
console.error(`Error: ${(error as Error).message}`);
|
||||
console.error('');
|
||||
@@ -84,7 +85,7 @@ export async function executeCopilotProfile(
|
||||
|
||||
// Check if copilot-api is installed (should be after ensureCopilotApi)
|
||||
if (!isCopilotApiInstalled()) {
|
||||
console.error('[X] copilot-api is not installed.');
|
||||
console.error(fail('copilot-api is not installed.'));
|
||||
console.error('');
|
||||
console.error('Install with: ccs copilot --install');
|
||||
return 1;
|
||||
@@ -93,7 +94,7 @@ export async function executeCopilotProfile(
|
||||
// Check authentication
|
||||
const authStatus = await checkAuthStatus();
|
||||
if (!authStatus.authenticated) {
|
||||
console.error('[X] Not authenticated with GitHub.');
|
||||
console.error(fail('Not authenticated with GitHub.'));
|
||||
console.error('');
|
||||
console.error('Run: npx copilot-api auth');
|
||||
console.error('Or: ccs copilot auth');
|
||||
@@ -105,16 +106,16 @@ export async function executeCopilotProfile(
|
||||
|
||||
if (!daemonRunning) {
|
||||
if (config.auto_start) {
|
||||
console.log('[i] Starting copilot-api daemon...');
|
||||
console.log(info('Starting copilot-api daemon...'));
|
||||
const result = await startDaemon(config);
|
||||
if (!result.success) {
|
||||
console.error(`[X] Failed to start daemon: ${result.error}`);
|
||||
console.error(fail(`Failed to start daemon: ${result.error}`));
|
||||
return 1;
|
||||
}
|
||||
console.log(`[OK] Daemon started on port ${config.port}`);
|
||||
console.log(ok(`Daemon started on port ${config.port}`));
|
||||
daemonRunning = true;
|
||||
} else {
|
||||
console.error('[X] copilot-api daemon is not running.');
|
||||
console.error(fail('copilot-api daemon is not running.'));
|
||||
console.error('');
|
||||
console.error('Start the daemon manually:');
|
||||
console.error(` npx copilot-api start --port ${config.port}`);
|
||||
@@ -139,7 +140,7 @@ export async function executeCopilotProfile(
|
||||
...copilotEnv,
|
||||
};
|
||||
|
||||
console.log(`[i] Using GitHub Copilot proxy (model: ${config.model})`);
|
||||
console.log(info(`Using GitHub Copilot proxy (model: ${config.model})`));
|
||||
console.log('');
|
||||
|
||||
// Spawn Claude CLI
|
||||
@@ -155,7 +156,7 @@ export async function executeCopilotProfile(
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
console.error(`[X] Failed to start Claude: ${err.message}`);
|
||||
console.error(fail(`Failed to start Claude: ${err.message}`));
|
||||
resolve(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Health Check Types and Interfaces
|
||||
*/
|
||||
|
||||
import { ok, fail, warn, info } from '../../utils/ui';
|
||||
|
||||
/**
|
||||
* Spinner interface for ora or fallback
|
||||
*/
|
||||
@@ -98,14 +100,14 @@ export function createSpinner(): (text: string) => Spinner {
|
||||
const oraModule = require('ora');
|
||||
return oraModule.default || oraModule;
|
||||
} catch (_e) {
|
||||
// ora not available, create fallback spinner that uses console.log
|
||||
// ora not available, create fallback spinner that uses console.log with UI colors
|
||||
return function (text: string): Spinner {
|
||||
return {
|
||||
start: () => ({
|
||||
succeed: (msg?: string) => console.log(msg || `[OK] ${text}`),
|
||||
fail: (msg?: string) => console.log(msg || `[X] ${text}`),
|
||||
warn: (msg?: string) => console.log(msg || `[!] ${text}`),
|
||||
info: (msg?: string) => console.log(msg || `[i] ${text}`),
|
||||
succeed: (msg?: string) => console.log(msg || ok(text)),
|
||||
fail: (msg?: string) => console.log(msg || fail(text)),
|
||||
warn: (msg?: string) => console.log(msg || warn(text)),
|
||||
info: (msg?: string) => console.log(msg || info(text)),
|
||||
text: '',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { ok, warn } from './ui';
|
||||
import { ok, fail, warn, info } from './ui';
|
||||
|
||||
// Ora fallback type for when ora is not available
|
||||
interface OraSpinner {
|
||||
@@ -27,14 +27,14 @@ try {
|
||||
const oraModule = require('ora');
|
||||
ora = oraModule.default || oraModule;
|
||||
} catch {
|
||||
// ora not available, create fallback spinner that uses console.log
|
||||
// ora not available, create fallback spinner that uses console.log with UI colors
|
||||
ora = function (text: string): OraInstance {
|
||||
return {
|
||||
start: () => ({
|
||||
succeed: (msg?: string) => console.log(msg || `[OK] ${text}`),
|
||||
fail: (msg?: string) => console.log(msg || `[X] ${text}`),
|
||||
warn: (msg?: string) => console.log(msg || `[!] ${text}`),
|
||||
info: (msg?: string) => console.log(msg || `[i] ${text}`),
|
||||
succeed: (msg?: string) => console.log(msg || ok(text)),
|
||||
fail: (msg?: string) => console.log(msg || fail(text)),
|
||||
warn: (msg?: string) => console.log(msg || warn(text)),
|
||||
info: (msg?: string) => console.log(msg || info(text)),
|
||||
text: '',
|
||||
}),
|
||||
};
|
||||
@@ -85,11 +85,11 @@ export class ClaudeDirInstaller {
|
||||
if (!fs.existsSync(packageClaudeDir)) {
|
||||
const msg = 'Package .claude/ directory not found';
|
||||
if (spinner) {
|
||||
spinner.warn(`[!] ${msg}`);
|
||||
spinner.warn(warn(msg));
|
||||
console.log(` Searched in: ${packageClaudeDir}`);
|
||||
console.log(' This may be a development installation');
|
||||
} else {
|
||||
console.log(`[!] ${msg}`);
|
||||
console.log(warn(msg));
|
||||
console.log(` Searched in: ${packageClaudeDir}`);
|
||||
console.log(' This may be a development installation');
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export class ClaudeDirInstaller {
|
||||
if (spinner) {
|
||||
spinner.succeed(ok(msg));
|
||||
} else {
|
||||
console.log(`[OK] ${msg}`);
|
||||
console.log(ok(msg));
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -129,7 +129,7 @@ export class ClaudeDirInstaller {
|
||||
spinner.fail(warn(msg));
|
||||
console.warn(' CCS items may not be available');
|
||||
} else {
|
||||
console.warn(`[!] ${msg}`);
|
||||
console.warn(warn(msg));
|
||||
console.warn(' CCS items may not be available');
|
||||
}
|
||||
return false;
|
||||
@@ -223,14 +223,14 @@ export class ClaudeDirInstaller {
|
||||
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)}`);
|
||||
if (!silent) console.log(info(`Backed up user file to ${path.basename(backupPath)}`));
|
||||
cleanedFiles.push('user file (backed up)');
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
// File doesn't exist or other error - that's okay
|
||||
if (error.code !== 'ENOENT' && !silent) {
|
||||
console.log(`[!] Failed to remove user symlink: ${error.message}`);
|
||||
console.log(warn(`Failed to remove user symlink: ${error.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,14 +245,16 @@ export class ClaudeDirInstaller {
|
||||
const backupPath = `${deprecatedFile}.backup-${timestamp}`;
|
||||
fs.renameSync(deprecatedFile, backupPath);
|
||||
if (!silent)
|
||||
console.log(`[i] Backed up modified deprecated file to ${path.basename(backupPath)}`);
|
||||
console.log(
|
||||
info(`Backed up modified deprecated file to ${path.basename(backupPath)}`)
|
||||
);
|
||||
} else {
|
||||
fs.rmSync(deprecatedFile, { force: true });
|
||||
}
|
||||
cleanedFiles.push('package copy');
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
if (!silent) console.log(`[!] Failed to remove package copy: ${error.message}`);
|
||||
if (!silent) console.log(warn(`Failed to remove package copy: ${error.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,14 +267,14 @@ export class ClaudeDirInstaller {
|
||||
fs.writeFileSync(migrationMarker, new Date().toISOString());
|
||||
|
||||
if (!silent) {
|
||||
console.log(`[OK] Cleaned up deprecated agent files: ${cleanedFiles.join(', ')}`);
|
||||
console.log(ok(`Cleaned up deprecated agent files: ${cleanedFiles.join(', ')}`));
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, cleanedFiles };
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
if (!silent) console.log(`[!] Cleanup failed: ${error.message}`);
|
||||
if (!silent) console.log(warn(`Cleanup failed: ${error.message}`));
|
||||
return { success: false, error: error.message, cleanedFiles };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { ok, color } from './ui';
|
||||
import { ok, fail, warn, info, color } from './ui';
|
||||
|
||||
// Ora fallback type for when ora is not available
|
||||
interface OraSpinner {
|
||||
@@ -36,14 +36,14 @@ try {
|
||||
const oraModule = require('ora');
|
||||
ora = oraModule.default || oraModule;
|
||||
} catch {
|
||||
// ora not available, create fallback spinner that uses console.log
|
||||
// ora not available, create fallback spinner that uses console.log with UI colors
|
||||
ora = function (text: string): OraInstance {
|
||||
return {
|
||||
start: () => ({
|
||||
succeed: (msg?: string) => console.log(msg || `[OK] ${text}`),
|
||||
fail: (msg?: string) => console.log(msg || `[X] ${text}`),
|
||||
warn: (msg?: string) => console.log(msg || `[!] ${text}`),
|
||||
info: (msg?: string) => console.log(msg || `[i] ${text}`),
|
||||
succeed: (msg?: string) => console.log(msg || ok(text)),
|
||||
fail: (msg?: string) => console.log(msg || fail(text)),
|
||||
warn: (msg?: string) => console.log(msg || warn(text)),
|
||||
info: (msg?: string) => console.log(msg || info(text)),
|
||||
text: '',
|
||||
}),
|
||||
};
|
||||
@@ -94,9 +94,9 @@ export class ClaudeSymlinkManager {
|
||||
if (!fs.existsSync(this.ccsClaudeDir)) {
|
||||
const msg = 'CCS .claude/ directory not found, skipping symlink installation';
|
||||
if (spinner) {
|
||||
spinner.warn(`[!] ${msg}`);
|
||||
spinner.warn(warn(msg));
|
||||
} else {
|
||||
console.log(`[!] ${msg}`);
|
||||
console.log(warn(msg));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export class ClaudeSymlinkManager {
|
||||
if (spinner) {
|
||||
spinner.succeed(ok(msg));
|
||||
} else {
|
||||
console.log(`[OK] ${msg}`);
|
||||
console.log(ok(msg));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export class ClaudeSymlinkManager {
|
||||
|
||||
// Ensure source exists
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
if (!silent) console.log(`[!] Source not found: ${item.source}, skipping`);
|
||||
if (!silent) console.log(warn(`Source not found: ${item.source}, skipping`));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export class ClaudeSymlinkManager {
|
||||
try {
|
||||
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
|
||||
fs.symlinkSync(sourcePath, targetPath, symlinkType);
|
||||
if (!silent) console.log(`[OK] Symlinked ${item.target}`);
|
||||
if (!silent) console.log(ok(`Symlinked ${item.target}`));
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Windows fallback: copy instead of symlink when symlinks unavailable
|
||||
@@ -169,7 +169,7 @@ export class ClaudeSymlinkManager {
|
||||
return this.copyFallback(sourcePath, targetPath, item, silent);
|
||||
} else {
|
||||
const error = err as Error;
|
||||
if (!silent) console.log(`[!] Failed to symlink ${item.target}: ${error.message}`);
|
||||
if (!silent) console.log(warn(`Failed to symlink ${item.target}: ${error.message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -194,15 +194,15 @@ export class ClaudeSymlinkManager {
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
}
|
||||
if (!silent) {
|
||||
console.log(`[OK] Copied ${item.target} (symlink unavailable)`);
|
||||
console.log(`[i] Run 'ccs sync' after CCS updates to refresh`);
|
||||
console.log(ok(`Copied ${item.target} (symlink unavailable)`));
|
||||
console.log(info("Run 'ccs sync' after CCS updates to refresh"));
|
||||
}
|
||||
return true;
|
||||
} catch (copyErr) {
|
||||
const error = copyErr as Error;
|
||||
if (!silent) {
|
||||
console.log(`[!] Failed to copy ${item.target}: ${error.message}`);
|
||||
console.log(`[i] Enable Developer Mode for symlinks, or check permissions`);
|
||||
console.log(warn(`Failed to copy ${item.target}: ${error.message}`));
|
||||
console.log(info('Enable Developer Mode for symlinks, or check permissions'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -267,10 +267,11 @@ export class ClaudeSymlinkManager {
|
||||
}
|
||||
|
||||
fs.renameSync(itemPath, finalBackupPath);
|
||||
if (!silent) console.log(`[i] Backed up existing item to ${path.basename(finalBackupPath)}`);
|
||||
if (!silent)
|
||||
console.log(info(`Backed up existing item to ${path.basename(finalBackupPath)}`));
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
if (!silent) console.log(`[!] Failed to backup ${itemPath}: ${error.message}`);
|
||||
if (!silent) console.log(warn(`Failed to backup ${itemPath}: ${error.message}`));
|
||||
throw err; // Don't proceed if backup fails
|
||||
}
|
||||
}
|
||||
@@ -300,19 +301,19 @@ export class ClaudeSymlinkManager {
|
||||
// Remove symlink or file
|
||||
fs.unlinkSync(targetPath);
|
||||
}
|
||||
console.log(`[OK] Removed ${item.target}`);
|
||||
console.log(ok(`Removed ${item.target}`));
|
||||
removed++;
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.log(`[!] Failed to remove ${item.target}: ${error.message}`);
|
||||
console.log(warn(`Failed to remove ${item.target}: ${error.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
console.log(`[OK] Removed ${removed} delegation commands and skills from ~/.claude/`);
|
||||
console.log(ok(`Removed ${removed} delegation commands and skills from ~/.claude/`));
|
||||
} else {
|
||||
console.log('[i] No delegation commands or skills to remove');
|
||||
console.log(info('No delegation commands or skills to remove'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* Simple Progress Indicator (no external dependencies)
|
||||
* Simple Progress Indicator
|
||||
*
|
||||
* Features:
|
||||
* - ASCII-only spinner frames (cross-platform compatible)
|
||||
* - TTY detection (no spinners in pipes/logs)
|
||||
* - Elapsed time display
|
||||
* - CI environment detection
|
||||
* - Color support via UI system
|
||||
*/
|
||||
|
||||
import { color } from './ui/colors';
|
||||
|
||||
interface ProgressOptions {
|
||||
frames?: string[];
|
||||
interval?: number;
|
||||
@@ -44,7 +47,7 @@ export class ProgressIndicator {
|
||||
start(): void {
|
||||
if (!this.isTTY) {
|
||||
// Non-TTY: just print message once
|
||||
process.stderr.write(`[i] ${this.message}...\n`);
|
||||
process.stderr.write(`${color('[i]', 'info')} ${this.message}...\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -52,7 +55,7 @@ export class ProgressIndicator {
|
||||
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)`);
|
||||
process.stderr.write(`\r${color(`[${frame}]`, 'info')} ${this.message}... (${elapsed}s)`);
|
||||
this.frameIndex = (this.frameIndex + 1) % this.frames.length;
|
||||
}, 80); // 12.5fps for smooth animation
|
||||
}
|
||||
@@ -68,10 +71,10 @@ export class ProgressIndicator {
|
||||
|
||||
if (this.isTTY) {
|
||||
// Clear spinner line and show success
|
||||
process.stderr.write(`\r[OK] ${finalMessage} (${elapsed}s)\n`);
|
||||
process.stderr.write(`\r${color('[OK]', 'success')} ${finalMessage} (${elapsed}s)\n`);
|
||||
} else {
|
||||
// Non-TTY: just show completion
|
||||
process.stderr.write(`[OK] ${finalMessage}\n`);
|
||||
process.stderr.write(`${color('[OK]', 'success')} ${finalMessage}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,10 +88,10 @@ export class ProgressIndicator {
|
||||
|
||||
if (this.isTTY) {
|
||||
// Clear spinner line and show failure
|
||||
process.stderr.write(`\r[X] ${finalMessage}\n`);
|
||||
process.stderr.write(`\r${color('[X]', 'error')} ${finalMessage}\n`);
|
||||
} else {
|
||||
// Non-TTY: just show failure
|
||||
process.stderr.write(`[X] ${finalMessage}\n`);
|
||||
process.stderr.write(`${color('[X]', 'error')} ${finalMessage}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export function hasAnyWebSearchCli(): boolean {
|
||||
|
||||
/**
|
||||
* Get install hints for CLI-only users when no WebSearch CLI is installed
|
||||
* Returns raw message strings (without indicator prefix) for display
|
||||
*/
|
||||
export function getCliInstallHints(): string[] {
|
||||
if (hasAnyWebSearchCli()) {
|
||||
@@ -78,7 +79,7 @@ export function getCliInstallHints(): string[] {
|
||||
}
|
||||
|
||||
return [
|
||||
'[i] WebSearch: No CLI tools installed',
|
||||
'WebSearch: No CLI tools installed',
|
||||
' Gemini CLI (FREE): npm i -g @google/gemini-cli',
|
||||
' OpenCode (FREE): curl -fsSL https://opencode.ai/install | bash',
|
||||
' Grok CLI (paid): npm i -g @vibe-kit/grok-cli',
|
||||
@@ -182,8 +183,13 @@ export function displayWebSearchStatus(): void {
|
||||
console.error(fail(`WebSearch: ${status.message}`));
|
||||
const hints = getCliInstallHints();
|
||||
if (hints.length > 0) {
|
||||
for (const hint of hints) {
|
||||
console.error(info(hint));
|
||||
// First line gets [i] prefix, rest are continuation (indented, no prefix)
|
||||
for (let i = 0; i < hints.length; i++) {
|
||||
if (i === 0) {
|
||||
console.error(info(hints[i]));
|
||||
} else {
|
||||
console.error(hints[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user