@leynier/ccst
Advanced tools
| import { spawnSync } from "node:child_process"; | ||
| import pc from "picocolors"; | ||
| import { promptInput } from "../../utils/interactive.js"; | ||
| type PackageManager = { | ||
| name: string; | ||
| displayCommand: string; | ||
| command: string; | ||
| args: string[]; | ||
| }; | ||
| const packageManagers: PackageManager[] = [ | ||
| { | ||
| name: "bun", | ||
| displayCommand: "bun add -g @kaitranntt/ccs", | ||
| command: "bun", | ||
| args: ["add", "-g", "@kaitranntt/ccs"], | ||
| }, | ||
| { | ||
| name: "npm", | ||
| displayCommand: "npm install -g @kaitranntt/ccs", | ||
| command: "npm", | ||
| args: ["install", "-g", "@kaitranntt/ccs"], | ||
| }, | ||
| { | ||
| name: "pnpm", | ||
| displayCommand: "pnpm add -g @kaitranntt/ccs", | ||
| command: "pnpm", | ||
| args: ["add", "-g", "@kaitranntt/ccs"], | ||
| }, | ||
| { | ||
| name: "yarn", | ||
| displayCommand: "yarn global add @kaitranntt/ccs", | ||
| command: "yarn", | ||
| args: ["global", "add", "@kaitranntt/ccs"], | ||
| }, | ||
| ]; | ||
| const selectPackageManager = async (): Promise<PackageManager | undefined> => { | ||
| const lines = packageManagers.map( | ||
| (pm, index) => `${index + 1}. ${pm.name} (${pm.displayCommand})`, | ||
| ); | ||
| console.log("Select package manager to install @kaitranntt/ccs:"); | ||
| console.log(lines.join("\n")); | ||
| const input = await promptInput("Select option (1-4)"); | ||
| const index = Number.parseInt(input || "", 10); | ||
| if (!Number.isFinite(index) || index < 1 || index > packageManagers.length) { | ||
| console.log(pc.red("Invalid selection")); | ||
| return undefined; | ||
| } | ||
| return packageManagers[index - 1]; | ||
| }; | ||
| const verifyInstallation = async (): Promise<string | null> => { | ||
| const result = spawnSync("ccs", ["--version"], { | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| encoding: "utf8", | ||
| }); | ||
| if (result.status !== 0) { | ||
| return null; | ||
| } | ||
| const version = result.stdout?.trim(); | ||
| return version && version.length > 0 ? version : null; | ||
| }; | ||
| const promptForSetup = async (): Promise<boolean> => { | ||
| const response = await promptInput("Run ccs setup now? (y/n)"); | ||
| return response?.toLowerCase() === "y"; | ||
| }; | ||
| export const ccsInstallCommand = async (): Promise<void> => { | ||
| // Step 1: Select package manager | ||
| const selectedPm = await selectPackageManager(); | ||
| if (!selectedPm) { | ||
| console.log(pc.dim("Installation cancelled")); | ||
| return; | ||
| } | ||
| // Step 2: Execute installation | ||
| console.log( | ||
| pc.dim( | ||
| `Installing @kaitranntt/ccs using ${selectedPm.name}... (this may take a moment)`, | ||
| ), | ||
| ); | ||
| const installResult = spawnSync(selectedPm.command, selectedPm.args, { | ||
| stdio: "inherit", | ||
| }); | ||
| if (installResult.status !== 0) { | ||
| console.log( | ||
| pc.red( | ||
| `Error: Installation failed with exit code ${installResult.status}`, | ||
| ), | ||
| ); | ||
| return; | ||
| } | ||
| // Step 3: Verify installation | ||
| console.log(pc.dim("Verifying installation...")); | ||
| const version = await verifyInstallation(); | ||
| if (!version) { | ||
| console.log( | ||
| pc.yellow("Warning: ccs installed but could not verify installation"), | ||
| ); | ||
| console.log(pc.dim("You may need to restart your terminal")); | ||
| console.log(pc.dim("Try running 'which ccs' or 'ccs --version' manually")); | ||
| return; | ||
| } | ||
| console.log(pc.green(`ccs installed successfully (${version})`)); | ||
| // Step 4: Ask if user wants to run setup | ||
| const shouldRunSetup = await promptForSetup(); | ||
| if (shouldRunSetup) { | ||
| console.log(pc.dim("Running ccs setup...")); | ||
| const setupResult = spawnSync("ccs", ["setup"], { stdio: "inherit" }); | ||
| if (setupResult.status === 0) { | ||
| console.log(pc.green("Setup completed successfully")); | ||
| } else { | ||
| console.log( | ||
| pc.yellow( | ||
| `Setup exited with code ${setupResult.status} (this may not be an error)`, | ||
| ), | ||
| ); | ||
| } | ||
| } else { | ||
| console.log(pc.dim("You can run 'ccst ccs setup' later to configure ccs")); | ||
| } | ||
| }; |
| import { spawnSync } from "node:child_process"; | ||
| import pc from "picocolors"; | ||
| export type SetupOptions = { | ||
| force?: boolean; | ||
| }; | ||
| export const ccsSetupCommand = async ( | ||
| options?: SetupOptions, | ||
| ): Promise<void> => { | ||
| // Check if ccs is installed | ||
| const which = spawnSync("which", ["ccs"], { stdio: "ignore" }); | ||
| if (which.status !== 0) { | ||
| console.log(pc.red("Error: ccs command not found")); | ||
| console.log(pc.dim("Run 'ccst ccs install' to install it")); | ||
| return; | ||
| } | ||
| // Build arguments | ||
| const args = ["setup"]; | ||
| if (options?.force) { | ||
| args.push("--force"); | ||
| } | ||
| // Execute ccs setup with real-time output | ||
| const result = spawnSync("ccs", args, { | ||
| stdio: "inherit", | ||
| }); | ||
| // Check exit code | ||
| if (result.status !== 0) { | ||
| console.log( | ||
| pc.red(`Error: ccs setup failed with exit code ${result.status}`), | ||
| ); | ||
| return; | ||
| } | ||
| console.log(pc.green("Setup completed successfully")); | ||
| }; |
+2
-1
| { | ||
| "name": "@leynier/ccst", | ||
| "version": "0.5.3", | ||
| "version": "0.7.0", | ||
| "description": "Claude Code Switch Tools for managing contexts", | ||
@@ -35,2 +35,3 @@ "keywords": [ | ||
| "commander": "^12.1.0", | ||
| "get-port": "^7.1.0", | ||
| "jszip": "^3.10.1", | ||
@@ -37,0 +38,0 @@ "picocolors": "^1.1.0" |
| import { spawn } from "node:child_process"; | ||
| import { openSync } from "node:fs"; | ||
| import { openSync, unlinkSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import getPort from "get-port"; | ||
| import pc from "picocolors"; | ||
@@ -7,2 +10,3 @@ import { | ||
| getCcsExecutable, | ||
| getCliproxyPort, | ||
| getLogPath, | ||
@@ -13,3 +17,5 @@ getProcessByPort, | ||
| killProcessTree, | ||
| truncateFile, | ||
| writePid, | ||
| writePorts, | ||
| } from "../../utils/daemon.js"; | ||
@@ -19,2 +25,4 @@ | ||
| force?: boolean; | ||
| keepLogs?: boolean; | ||
| port?: number; | ||
| }; | ||
@@ -50,24 +58,74 @@ | ||
| const logPath = getLogPath(); | ||
| if (!options?.keepLogs) { | ||
| try { | ||
| await truncateFile(logPath); | ||
| } catch { | ||
| console.warn( | ||
| pc.yellow( | ||
| `Warning: could not truncate log; continuing (logs will be appended): ${logPath}`, | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
| // Detect ports | ||
| const cliproxyPort = await getCliproxyPort(); | ||
| const dashboardPort = | ||
| options?.port ?? | ||
| (await getPort({ | ||
| port: [3000, 3001, 3002, 8000, 8080], | ||
| })); | ||
| // Save ports for stop command | ||
| await writePorts({ dashboard: dashboardPort, cliproxy: cliproxyPort }); | ||
| console.log( | ||
| pc.dim( | ||
| `Using dashboard port: ${dashboardPort}, CLIProxy port: ${cliproxyPort}`, | ||
| ), | ||
| ); | ||
| const ccsPath = getCcsExecutable(); | ||
| let pid: number | undefined; | ||
| if (process.platform === "win32") { | ||
| // On Windows, use cmd /c start /B to launch without creating a new window | ||
| // This works with npm-installed .cmd wrappers that create their own console | ||
| const proc = spawn("cmd", ["/c", `start /B "" "${ccsPath}" config`], { | ||
| // VBScript is the ONLY reliable way to run a process completely hidden on Windows | ||
| // WScript.Shell.Run with 0 = hidden window, False = don't wait | ||
| // Redirect output to log file using cmd /c with shell redirection | ||
| const escapedLogPath = logPath.replace(/\\/g, "\\\\"); | ||
| const vbsContent = `CreateObject("WScript.Shell").Run "cmd /c ${ccsPath} config --port ${dashboardPort} >> ${escapedLogPath} 2>&1", 0, False`; | ||
| const vbsPath = join(tmpdir(), `ccs-start-${Date.now()}.vbs`); | ||
| await Bun.write(vbsPath, vbsContent); | ||
| // Run the vbs file (wscript itself doesn't show a window) | ||
| const proc = spawn("wscript", [vbsPath], { | ||
| detached: true, | ||
| stdio: "ignore", | ||
| windowsHide: true, | ||
| detached: true, | ||
| }); | ||
| proc.unref(); | ||
| // Wait for the process to start, then find it by port | ||
| console.log(pc.dim("Starting CCS config daemon...")); | ||
| await new Promise((resolve) => setTimeout(resolve, 2000)); | ||
| // Clean up the vbs file after a short delay | ||
| setTimeout(() => { | ||
| try { | ||
| unlinkSync(vbsPath); | ||
| } catch {} | ||
| }, 1000); | ||
| // Find the process by port 3000 (dashboard port) | ||
| const foundPid = await getProcessByPort(3000); | ||
| // Poll for the port to become available | ||
| // ccs config takes ~6s to start (5s CLIProxy timeout + dashboard startup) | ||
| console.log( | ||
| pc.dim("Starting CCS config daemon (this may take a few seconds)..."), | ||
| ); | ||
| const maxWaitMs = 15000; // 15 seconds max | ||
| const pollIntervalMs = 500; | ||
| const startTime = Date.now(); | ||
| let foundPid: number | null = null; | ||
| while (Date.now() - startTime < maxWaitMs) { | ||
| foundPid = await getProcessByPort(dashboardPort); | ||
| if (foundPid !== null) break; | ||
| await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); | ||
| } | ||
| if (foundPid === null) { | ||
| console.log(pc.red("Failed to start CCS config daemon")); | ||
| console.log( | ||
| pc.dim("Check if ccs is installed: npm install -g @anthropic/ccs"), | ||
| pc.dim("Check if ccs is installed: npm install -g @kaitranntt/ccs"), | ||
| ); | ||
@@ -80,3 +138,3 @@ return; | ||
| const logFd = openSync(logPath, "a"); | ||
| const child = spawn(ccsPath, ["config"], { | ||
| const child = spawn(ccsPath, ["config", "--port", String(dashboardPort)], { | ||
| detached: true, | ||
@@ -83,0 +141,0 @@ stdio: ["ignore", logFd, logFd], |
| import pc from "picocolors"; | ||
| import { | ||
| CCS_PORTS, | ||
| getPortsToKill, | ||
| getRunningDaemonPid, | ||
@@ -9,2 +9,3 @@ isProcessRunning, | ||
| removePid, | ||
| removePorts, | ||
| } from "../../utils/daemon.js"; | ||
@@ -36,3 +37,5 @@ | ||
| // Phase 2: Kill processes by port (especially important on Windows) | ||
| for (const port of CCS_PORTS) { | ||
| // Use saved ports or fallback to defaults | ||
| const ports = await getPortsToKill(); | ||
| for (const port of ports) { | ||
| const killed = await killProcessByPort(port, options?.force ?? true); | ||
@@ -44,2 +47,4 @@ if (killed) { | ||
| } | ||
| // Clean up ports file | ||
| removePorts(); | ||
| if (!stopped) { | ||
@@ -46,0 +51,0 @@ console.log(pc.yellow("CCS config daemon is not running")); |
+25
-1
| #!/usr/bin/env bun | ||
| import { Command } from "commander"; | ||
| import pkg from "../package.json"; | ||
| import { ccsInstallCommand } from "./commands/ccs/install.js"; | ||
| import { ccsLogsCommand } from "./commands/ccs/logs.js"; | ||
| import { ccsSetupCommand } from "./commands/ccs/setup.js"; | ||
| import { ccsStartCommand } from "./commands/ccs/start.js"; | ||
@@ -191,4 +193,13 @@ import { ccsStatusCommand } from "./commands/ccs/status.js"; | ||
| .option("-f, --force", "Force restart if already running") | ||
| .option("--keep-logs", "Keep existing log file (append)") | ||
| .option( | ||
| "-p, --port <number>", | ||
| "Dashboard port (auto-detect if not specified)", | ||
| ) | ||
| .action(async (options) => { | ||
| await ccsStartCommand(options); | ||
| await ccsStartCommand({ | ||
| force: options.force, | ||
| keepLogs: options.keepLogs, | ||
| port: options.port ? Number.parseInt(options.port, 10) : undefined, | ||
| }); | ||
| }); | ||
@@ -219,2 +230,15 @@ ccsCommandGroup | ||
| }); | ||
| ccsCommandGroup | ||
| .command("setup") | ||
| .description("Run CCS initial setup") | ||
| .option("-f, --force", "Force setup even if already configured") | ||
| .action(async (options) => { | ||
| await ccsSetupCommand(options); | ||
| }); | ||
| ccsCommandGroup | ||
| .command("install") | ||
| .description("Install CCS CLI tool") | ||
| .action(async () => { | ||
| await ccsInstallCommand(); | ||
| }); | ||
| try { | ||
@@ -221,0 +245,0 @@ await program.parseAsync(process.argv); |
+83
-2
@@ -14,2 +14,7 @@ import { existsSync, mkdirSync, unlinkSync } from "node:fs"; | ||
| // Truncate a file (or create it empty if missing) | ||
| export const truncateFile = async (filePath: string): Promise<void> => { | ||
| await Bun.write(filePath, ""); | ||
| }; | ||
| // Ensure daemon directory exists | ||
@@ -86,5 +91,81 @@ export const ensureDaemonDir = (): void => { | ||
| // Known CCS daemon ports | ||
| export const CCS_PORTS = [3000, 8317]; | ||
| // Default CCS daemon ports (fallback) | ||
| export const DEFAULT_DASHBOARD_PORT = 3000; | ||
| export const DEFAULT_CLIPROXY_PORT = 8317; | ||
| // Ports file path | ||
| export const getPortsPath = (): string => join(getDaemonDir(), "ports.json"); | ||
| // Type for daemon ports | ||
| export type DaemonPorts = { | ||
| dashboard: number; | ||
| cliproxy: number; | ||
| }; | ||
| // Read saved ports | ||
| export const readPorts = async (): Promise<DaemonPorts | null> => { | ||
| const portsPath = getPortsPath(); | ||
| if (!existsSync(portsPath)) { | ||
| return null; | ||
| } | ||
| try { | ||
| const content = await Bun.file(portsPath).text(); | ||
| const ports = JSON.parse(content) as DaemonPorts; | ||
| if ( | ||
| typeof ports.dashboard === "number" && | ||
| typeof ports.cliproxy === "number" | ||
| ) { | ||
| return ports; | ||
| } | ||
| return null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| }; | ||
| // Write ports to file | ||
| export const writePorts = async (ports: DaemonPorts): Promise<void> => { | ||
| ensureDaemonDir(); | ||
| await Bun.write(getPortsPath(), JSON.stringify(ports, null, 2)); | ||
| }; | ||
| // Remove ports file | ||
| export const removePorts = (): void => { | ||
| const portsPath = getPortsPath(); | ||
| if (existsSync(portsPath)) { | ||
| unlinkSync(portsPath); | ||
| } | ||
| }; | ||
| // Read CLIProxy port from config.yaml | ||
| export const getCliproxyPort = async (): Promise<number> => { | ||
| const configPath = join(homedir(), ".ccs", "cliproxy", "config.yaml"); | ||
| if (!existsSync(configPath)) { | ||
| return DEFAULT_CLIPROXY_PORT; | ||
| } | ||
| try { | ||
| const content = await Bun.file(configPath).text(); | ||
| // Simple YAML parsing for "port: XXXX" | ||
| const match = content.match(/^port:\s*(\d+)/m); | ||
| if (match?.[1]) { | ||
| const port = Number.parseInt(match[1], 10); | ||
| if (Number.isFinite(port) && port > 0) { | ||
| return port; | ||
| } | ||
| } | ||
| return DEFAULT_CLIPROXY_PORT; | ||
| } catch { | ||
| return DEFAULT_CLIPROXY_PORT; | ||
| } | ||
| }; | ||
| // Get ports to kill (from saved file or defaults) | ||
| export const getPortsToKill = async (): Promise<number[]> => { | ||
| const saved = await readPorts(); | ||
| if (saved) { | ||
| return [saved.dashboard, saved.cliproxy]; | ||
| } | ||
| return [DEFAULT_DASHBOARD_PORT, DEFAULT_CLIPROXY_PORT]; | ||
| }; | ||
| // Kill process tree (on Windows, kills all child processes) | ||
@@ -91,0 +172,0 @@ export const killProcessTree = async ( |
@@ -74,2 +74,5 @@ import { spawnSync } from "node:child_process"; | ||
| process.stdin.off("end", onEnd); | ||
| if (process.stdin.isTTY) { | ||
| process.stdin.pause(); | ||
| } | ||
| }; | ||
@@ -76,0 +79,0 @@ if (process.stdin.isTTY) { |
94072
10.82%46
4.55%2654
13.23%5
25%+ Added
+ Added