@leynier/ccst
Advanced tools
| import { existsSync, unwatchFile, watchFile } from "node:fs"; | ||
| import pc from "picocolors"; | ||
| import { getLogPath } from "../../utils/daemon.js"; | ||
| export type LogsOptions = { | ||
| follow?: boolean; | ||
| lines?: number; | ||
| }; | ||
| export const ccsLogsCommand = async (options?: LogsOptions): Promise<void> => { | ||
| const logPath = getLogPath(); | ||
| if (!existsSync(logPath)) { | ||
| console.log(pc.yellow("No log file found")); | ||
| console.log(pc.dim(`Expected at: ${logPath}`)); | ||
| return; | ||
| } | ||
| const lines = options?.lines ?? 50; | ||
| const follow = options?.follow ?? false; | ||
| // On Unix, use native tail for better performance | ||
| if (process.platform !== "win32" && follow) { | ||
| const tailProcess = Bun.spawn( | ||
| ["tail", "-f", "-n", String(lines), logPath], | ||
| { | ||
| stdout: "inherit", | ||
| stderr: "inherit", | ||
| }, | ||
| ); | ||
| // Handle cleanup on Ctrl+C | ||
| const cleanup = () => { | ||
| tailProcess.kill(); | ||
| process.exit(0); | ||
| }; | ||
| process.on("SIGINT", cleanup); | ||
| process.on("SIGTERM", cleanup); | ||
| await tailProcess.exited; | ||
| return; | ||
| } | ||
| // Read last N lines | ||
| const content = await Bun.file(logPath).text(); | ||
| const allLines = content.split("\n"); | ||
| const lastLines = allLines.slice(-lines).join("\n"); | ||
| console.log(lastLines); | ||
| if (!follow) { | ||
| return; | ||
| } | ||
| // Follow mode for Windows: watch for changes | ||
| console.log(pc.dim("--- Following log file (Ctrl+C to stop) ---")); | ||
| let lastSize = (await Bun.file(logPath).stat()).size; | ||
| // Handle graceful shutdown | ||
| const cleanup = () => { | ||
| unwatchFile(logPath); | ||
| process.exit(0); | ||
| }; | ||
| process.on("SIGINT", cleanup); | ||
| process.on("SIGTERM", cleanup); | ||
| // Watch file for changes | ||
| watchFile(logPath, { interval: 500 }, async (curr) => { | ||
| if (curr.size > lastSize) { | ||
| // Read new content | ||
| const file = Bun.file(logPath); | ||
| const newContent = await file.slice(lastSize).text(); | ||
| process.stdout.write(newContent); | ||
| lastSize = curr.size; | ||
| } else if (curr.size < lastSize) { | ||
| // File was truncated/rotated | ||
| console.log(pc.dim("--- Log file rotated ---")); | ||
| const newContent = await Bun.file(logPath).text(); | ||
| process.stdout.write(newContent); | ||
| lastSize = curr.size; | ||
| } | ||
| }); | ||
| // Keep process alive | ||
| await new Promise(() => {}); | ||
| }; |
| import { spawn } from "node:child_process"; | ||
| import { openSync } from "node:fs"; | ||
| import pc from "picocolors"; | ||
| import { | ||
| ensureDaemonDir, | ||
| getCcsExecutable, | ||
| getLogPath, | ||
| getRunningDaemonPid, | ||
| isProcessRunning, | ||
| writePid, | ||
| } from "../../utils/daemon.js"; | ||
| export type StartOptions = { | ||
| force?: boolean; | ||
| }; | ||
| export const ccsStartCommand = async ( | ||
| options?: StartOptions, | ||
| ): Promise<void> => { | ||
| // Check if already running | ||
| const existingPid = await getRunningDaemonPid(); | ||
| if (existingPid !== null && !options?.force) { | ||
| console.log( | ||
| pc.yellow(`CCS config daemon is already running (PID: ${existingPid})`), | ||
| ); | ||
| console.log(pc.dim("Use --force to restart")); | ||
| return; | ||
| } | ||
| // If force and running, stop first | ||
| if (existingPid !== null && options?.force) { | ||
| console.log(pc.dim(`Stopping existing daemon (PID: ${existingPid})...`)); | ||
| try { | ||
| process.kill(existingPid, "SIGTERM"); | ||
| // Wait for process to terminate | ||
| const maxWait = 3000; | ||
| const startTime = Date.now(); | ||
| while (Date.now() - startTime < maxWait) { | ||
| if (!isProcessRunning(existingPid)) { | ||
| break; | ||
| } | ||
| await new Promise((resolve) => setTimeout(resolve, 100)); | ||
| } | ||
| } catch { | ||
| // Process may have already exited | ||
| } | ||
| } | ||
| ensureDaemonDir(); | ||
| const logPath = getLogPath(); | ||
| const ccsPath = getCcsExecutable(); | ||
| // Open log file for writing (append mode) | ||
| const logFd = openSync(logPath, "a"); | ||
| // Spawn detached process | ||
| const child = spawn(ccsPath, ["config"], { | ||
| detached: true, | ||
| stdio: ["ignore", logFd, logFd], | ||
| // On Windows, need shell: true for proper detachment | ||
| ...(process.platform === "win32" ? { shell: true } : {}), | ||
| }); | ||
| if (!child.pid) { | ||
| console.log(pc.red("Failed to start CCS config daemon")); | ||
| return; | ||
| } | ||
| await writePid(child.pid); | ||
| // Unref to allow parent to exit independently | ||
| child.unref(); | ||
| console.log(pc.green(`CCS config daemon started (PID: ${child.pid})`)); | ||
| console.log(pc.dim(`Logs: ${logPath}`)); | ||
| console.log(pc.dim("Run 'ccst ccs status' to check status")); | ||
| console.log(pc.dim("Run 'ccst ccs logs' to view logs")); | ||
| }; |
| import { existsSync, statSync } from "node:fs"; | ||
| import pc from "picocolors"; | ||
| import { | ||
| getLogPath, | ||
| getPidPath, | ||
| getRunningDaemonPid, | ||
| } from "../../utils/daemon.js"; | ||
| export const ccsStatusCommand = async (): Promise<void> => { | ||
| const pid = await getRunningDaemonPid(); | ||
| if (pid === null) { | ||
| console.log(pc.yellow("CCS config daemon is not running")); | ||
| return; | ||
| } | ||
| console.log(pc.green(`CCS config daemon is running (PID: ${pid})`)); | ||
| // Show additional info | ||
| const pidPath = getPidPath(); | ||
| const logPath = getLogPath(); | ||
| console.log(pc.dim(`PID file: ${pidPath}`)); | ||
| if (existsSync(logPath)) { | ||
| const stats = statSync(logPath); | ||
| const sizeKb = (stats.size / 1024).toFixed(2); | ||
| console.log(pc.dim(`Log file: ${logPath} (${sizeKb} KB)`)); | ||
| } | ||
| // Try to get process uptime (Unix only) | ||
| if (process.platform !== "win32") { | ||
| try { | ||
| const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "etime="], { | ||
| stdout: "pipe", | ||
| stderr: "ignore", | ||
| }); | ||
| const output = await new Response(proc.stdout).text(); | ||
| const uptime = output.trim(); | ||
| if (uptime) { | ||
| console.log(pc.dim(`Uptime: ${uptime}`)); | ||
| } | ||
| } catch { | ||
| // ps command not available or failed | ||
| } | ||
| } | ||
| }; |
| import pc from "picocolors"; | ||
| import { | ||
| getRunningDaemonPid, | ||
| isProcessRunning, | ||
| removePid, | ||
| } from "../../utils/daemon.js"; | ||
| export type StopOptions = { | ||
| force?: boolean; | ||
| }; | ||
| export const ccsStopCommand = async (options?: StopOptions): Promise<void> => { | ||
| const pid = await getRunningDaemonPid(); | ||
| if (pid === null) { | ||
| console.log(pc.yellow("CCS config daemon is not running")); | ||
| return; | ||
| } | ||
| try { | ||
| // Send SIGTERM for graceful shutdown, SIGKILL if --force | ||
| const signal = options?.force ? "SIGKILL" : "SIGTERM"; | ||
| process.kill(pid, signal); | ||
| // Wait for process to terminate (with timeout) | ||
| const maxWait = options?.force ? 1000 : 5000; | ||
| const startTime = Date.now(); | ||
| while (Date.now() - startTime < maxWait) { | ||
| if (!isProcessRunning(pid)) { | ||
| break; | ||
| } | ||
| await new Promise((resolve) => setTimeout(resolve, 100)); | ||
| } | ||
| removePid(); | ||
| console.log(pc.green(`CCS config daemon stopped (PID: ${pid})`)); | ||
| } catch (error) { | ||
| const err = error as NodeJS.ErrnoException; | ||
| if (err.code === "ESRCH") { | ||
| // Process doesn't exist - clean up stale PID file | ||
| removePid(); | ||
| console.log(pc.yellow("Process not found, cleaned up stale PID file")); | ||
| } else if (err.code === "EPERM") { | ||
| console.log( | ||
| pc.red("Permission denied. Try running with elevated privileges."), | ||
| ); | ||
| } else { | ||
| console.log(pc.red(`Failed to stop daemon: ${err.message}`)); | ||
| } | ||
| } | ||
| }; |
| import { existsSync, mkdirSync, unlinkSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| // Daemon directory inside CCS home | ||
| export const getDaemonDir = (): string => join(homedir(), ".ccs", "daemon"); | ||
| // PID file path | ||
| export const getPidPath = (): string => join(getDaemonDir(), "ccs-config.pid"); | ||
| // Log file path | ||
| export const getLogPath = (): string => join(getDaemonDir(), "ccs-config.log"); | ||
| // Ensure daemon directory exists | ||
| export const ensureDaemonDir = (): void => { | ||
| const dir = getDaemonDir(); | ||
| if (!existsSync(dir)) { | ||
| mkdirSync(dir, { recursive: true }); | ||
| } | ||
| }; | ||
| // Read PID from file | ||
| export const readPid = async (): Promise<number | null> => { | ||
| const pidPath = getPidPath(); | ||
| if (!existsSync(pidPath)) { | ||
| return null; | ||
| } | ||
| try { | ||
| const content = await Bun.file(pidPath).text(); | ||
| const pid = parseInt(content.trim(), 10); | ||
| return Number.isFinite(pid) ? pid : null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| }; | ||
| // Write PID to file | ||
| export const writePid = async (pid: number): Promise<void> => { | ||
| ensureDaemonDir(); | ||
| await Bun.write(getPidPath(), String(pid)); | ||
| }; | ||
| // Remove PID file | ||
| export const removePid = (): void => { | ||
| const pidPath = getPidPath(); | ||
| if (existsSync(pidPath)) { | ||
| unlinkSync(pidPath); | ||
| } | ||
| }; | ||
| // Check if process is running using kill(pid, 0) | ||
| export const isProcessRunning = (pid: number): boolean => { | ||
| try { | ||
| process.kill(pid, 0); | ||
| return true; | ||
| } catch (error) { | ||
| // EPERM = permission denied (but process exists) | ||
| if ((error as NodeJS.ErrnoException).code === "EPERM") { | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
| }; | ||
| // Get running daemon PID (validates process is actually running) | ||
| export const getRunningDaemonPid = async (): Promise<number | null> => { | ||
| const pid = await readPid(); | ||
| if (pid === null) { | ||
| return null; | ||
| } | ||
| if (!isProcessRunning(pid)) { | ||
| // Stale PID file - clean it up | ||
| removePid(); | ||
| return null; | ||
| } | ||
| return pid; | ||
| }; | ||
| // Locate ccs executable | ||
| export const getCcsExecutable = (): string => { | ||
| // Use 'ccs' from PATH - it should be globally installed | ||
| return "ccs"; | ||
| }; |
+1
-1
| { | ||
| "name": "@leynier/ccst", | ||
| "version": "0.3.2", | ||
| "version": "0.4.0", | ||
| "description": "Claude Code Switch Tools for managing contexts", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
+38
-0
| #!/usr/bin/env bun | ||
| import { Command } from "commander"; | ||
| import pkg from "../package.json"; | ||
| import { ccsLogsCommand } from "./commands/ccs/logs.js"; | ||
| import { ccsStartCommand } from "./commands/ccs/start.js"; | ||
| import { ccsStatusCommand } from "./commands/ccs/status.js"; | ||
| import { ccsStopCommand } from "./commands/ccs/stop.js"; | ||
| import { completionsCommand } from "./commands/completions.js"; | ||
@@ -180,2 +184,36 @@ import { configDumpCommand } from "./commands/config/dump.js"; | ||
| }); | ||
| const ccsCommandGroup = program | ||
| .command("ccs") | ||
| .description("CCS daemon management"); | ||
| ccsCommandGroup | ||
| .command("start") | ||
| .description("Start CCS config as background daemon") | ||
| .option("-f, --force", "Force restart if already running") | ||
| .action(async (options) => { | ||
| await ccsStartCommand(options); | ||
| }); | ||
| ccsCommandGroup | ||
| .command("stop") | ||
| .description("Stop the CCS config daemon") | ||
| .option("-f, --force", "Force kill (SIGKILL)") | ||
| .action(async (options) => { | ||
| await ccsStopCommand(options); | ||
| }); | ||
| ccsCommandGroup | ||
| .command("status") | ||
| .description("Check CCS config daemon status") | ||
| .action(async () => { | ||
| await ccsStatusCommand(); | ||
| }); | ||
| ccsCommandGroup | ||
| .command("logs") | ||
| .description("View CCS config daemon logs") | ||
| .option("-f, --follow", "Follow log output") | ||
| .option("-n, --lines <number>", "Number of lines", "50") | ||
| .action(async (options) => { | ||
| await ccsLogsCommand({ | ||
| follow: options.follow, | ||
| lines: parseInt(options.lines, 10), | ||
| }); | ||
| }); | ||
| try { | ||
@@ -182,0 +220,0 @@ await program.parseAsync(process.argv); |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
81497
14.03%44
12.82%2248
17.57%5
25%