@leynier/ccst 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leynier/ccst",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "Claude Code Switch Tools for managing contexts",
5
5
  "keywords": [
6
6
  "claude",
@@ -0,0 +1,74 @@
1
+ import { existsSync, unwatchFile, watchFile } from "node:fs";
2
+ import pc from "picocolors";
3
+ import { getLogPath } from "../../utils/daemon.js";
4
+
5
+ export type LogsOptions = {
6
+ follow?: boolean;
7
+ lines?: number;
8
+ };
9
+
10
+ export const ccsLogsCommand = async (options?: LogsOptions): Promise<void> => {
11
+ const logPath = getLogPath();
12
+ if (!existsSync(logPath)) {
13
+ console.log(pc.yellow("No log file found"));
14
+ console.log(pc.dim(`Expected at: ${logPath}`));
15
+ return;
16
+ }
17
+ const lines = options?.lines ?? 50;
18
+ const follow = options?.follow ?? false;
19
+ // On Unix, use native tail for better performance
20
+ if (process.platform !== "win32" && follow) {
21
+ const tailProcess = Bun.spawn(
22
+ ["tail", "-f", "-n", String(lines), logPath],
23
+ {
24
+ stdout: "inherit",
25
+ stderr: "inherit",
26
+ },
27
+ );
28
+ // Handle cleanup on Ctrl+C
29
+ const cleanup = () => {
30
+ tailProcess.kill();
31
+ process.exit(0);
32
+ };
33
+ process.on("SIGINT", cleanup);
34
+ process.on("SIGTERM", cleanup);
35
+ await tailProcess.exited;
36
+ return;
37
+ }
38
+ // Read last N lines
39
+ const content = await Bun.file(logPath).text();
40
+ const allLines = content.split("\n");
41
+ const lastLines = allLines.slice(-lines).join("\n");
42
+ console.log(lastLines);
43
+ if (!follow) {
44
+ return;
45
+ }
46
+ // Follow mode for Windows: watch for changes
47
+ console.log(pc.dim("--- Following log file (Ctrl+C to stop) ---"));
48
+ let lastSize = (await Bun.file(logPath).stat()).size;
49
+ // Handle graceful shutdown
50
+ const cleanup = () => {
51
+ unwatchFile(logPath);
52
+ process.exit(0);
53
+ };
54
+ process.on("SIGINT", cleanup);
55
+ process.on("SIGTERM", cleanup);
56
+ // Watch file for changes
57
+ watchFile(logPath, { interval: 500 }, async (curr) => {
58
+ if (curr.size > lastSize) {
59
+ // Read new content
60
+ const file = Bun.file(logPath);
61
+ const newContent = await file.slice(lastSize).text();
62
+ process.stdout.write(newContent);
63
+ lastSize = curr.size;
64
+ } else if (curr.size < lastSize) {
65
+ // File was truncated/rotated
66
+ console.log(pc.dim("--- Log file rotated ---"));
67
+ const newContent = await Bun.file(logPath).text();
68
+ process.stdout.write(newContent);
69
+ lastSize = curr.size;
70
+ }
71
+ });
72
+ // Keep process alive
73
+ await new Promise(() => {});
74
+ };
@@ -0,0 +1,67 @@
1
+ import { spawn } from "node:child_process";
2
+ import { openSync } from "node:fs";
3
+ import pc from "picocolors";
4
+ import {
5
+ ensureDaemonDir,
6
+ getCcsExecutable,
7
+ getLogPath,
8
+ getRunningDaemonPid,
9
+ isProcessRunning,
10
+ killProcessTree,
11
+ writePid,
12
+ } from "../../utils/daemon.js";
13
+
14
+ export type StartOptions = {
15
+ force?: boolean;
16
+ };
17
+
18
+ export const ccsStartCommand = async (
19
+ options?: StartOptions,
20
+ ): Promise<void> => {
21
+ // Check if already running
22
+ const existingPid = await getRunningDaemonPid();
23
+ if (existingPid !== null && !options?.force) {
24
+ console.log(
25
+ pc.yellow(`CCS config daemon is already running (PID: ${existingPid})`),
26
+ );
27
+ console.log(pc.dim("Use --force to restart"));
28
+ return;
29
+ }
30
+ // If force and running, stop first
31
+ if (existingPid !== null && options?.force) {
32
+ console.log(pc.dim(`Stopping existing daemon (PID: ${existingPid})...`));
33
+ await killProcessTree(existingPid, true);
34
+ // Wait for process to terminate
35
+ const maxWait = 3000;
36
+ const startTime = Date.now();
37
+ while (Date.now() - startTime < maxWait) {
38
+ if (!isProcessRunning(existingPid)) {
39
+ break;
40
+ }
41
+ await new Promise((resolve) => setTimeout(resolve, 100));
42
+ }
43
+ }
44
+ ensureDaemonDir();
45
+ const logPath = getLogPath();
46
+ const ccsPath = getCcsExecutable();
47
+ // Open log file for writing (append mode)
48
+ const logFd = openSync(logPath, "a");
49
+ // Spawn detached process
50
+ const child = spawn(ccsPath, ["config"], {
51
+ detached: true,
52
+ stdio: ["ignore", logFd, logFd],
53
+ // On Windows, need shell: true for proper detachment and windowsHide to hide console
54
+ ...(process.platform === "win32" ? { shell: true, windowsHide: true } : {}),
55
+ });
56
+ if (!child.pid) {
57
+ console.log(pc.red("Failed to start CCS config daemon"));
58
+ return;
59
+ }
60
+ await writePid(child.pid);
61
+ // Unref to allow parent to exit independently
62
+ child.unref();
63
+ console.log(pc.green(`CCS config daemon started (PID: ${child.pid})`));
64
+ console.log(pc.dim(`Logs: ${logPath}`));
65
+ console.log(pc.dim("Run 'ccst ccs status' to check status"));
66
+ console.log(pc.dim("Run 'ccst ccs logs' to view logs"));
67
+ };
@@ -0,0 +1,41 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import pc from "picocolors";
3
+ import {
4
+ getLogPath,
5
+ getPidPath,
6
+ getRunningDaemonPid,
7
+ } from "../../utils/daemon.js";
8
+
9
+ export const ccsStatusCommand = async (): Promise<void> => {
10
+ const pid = await getRunningDaemonPid();
11
+ if (pid === null) {
12
+ console.log(pc.yellow("CCS config daemon is not running"));
13
+ return;
14
+ }
15
+ console.log(pc.green(`CCS config daemon is running (PID: ${pid})`));
16
+ // Show additional info
17
+ const pidPath = getPidPath();
18
+ const logPath = getLogPath();
19
+ console.log(pc.dim(`PID file: ${pidPath}`));
20
+ if (existsSync(logPath)) {
21
+ const stats = statSync(logPath);
22
+ const sizeKb = (stats.size / 1024).toFixed(2);
23
+ console.log(pc.dim(`Log file: ${logPath} (${sizeKb} KB)`));
24
+ }
25
+ // Try to get process uptime (Unix only)
26
+ if (process.platform !== "win32") {
27
+ try {
28
+ const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "etime="], {
29
+ stdout: "pipe",
30
+ stderr: "ignore",
31
+ });
32
+ const output = await new Response(proc.stdout).text();
33
+ const uptime = output.trim();
34
+ if (uptime) {
35
+ console.log(pc.dim(`Uptime: ${uptime}`));
36
+ }
37
+ } catch {
38
+ // ps command not available or failed
39
+ }
40
+ }
41
+ };
@@ -0,0 +1,45 @@
1
+ import pc from "picocolors";
2
+ import {
3
+ CCS_PORTS,
4
+ getRunningDaemonPid,
5
+ isProcessRunning,
6
+ killProcessByPort,
7
+ killProcessTree,
8
+ removePid,
9
+ } from "../../utils/daemon.js";
10
+
11
+ export type StopOptions = {
12
+ force?: boolean;
13
+ };
14
+
15
+ export const ccsStopCommand = async (options?: StopOptions): Promise<void> => {
16
+ const pid = await getRunningDaemonPid();
17
+ let stopped = false;
18
+ // Phase 1: Kill by PID if exists
19
+ if (pid !== null) {
20
+ await killProcessTree(pid, options?.force);
21
+ // Wait for process to terminate (with timeout)
22
+ const maxWait = options?.force ? 1000 : 5000;
23
+ const startTime = Date.now();
24
+ while (Date.now() - startTime < maxWait) {
25
+ if (!isProcessRunning(pid)) {
26
+ break;
27
+ }
28
+ await new Promise((resolve) => setTimeout(resolve, 100));
29
+ }
30
+ removePid();
31
+ console.log(pc.green(`CCS config daemon stopped (PID: ${pid})`));
32
+ stopped = true;
33
+ }
34
+ // Phase 2: Kill processes by port (especially important on Windows)
35
+ for (const port of CCS_PORTS) {
36
+ const killed = await killProcessByPort(port, options?.force ?? true);
37
+ if (killed) {
38
+ console.log(pc.dim(`Cleaned up process on port ${port}`));
39
+ stopped = true;
40
+ }
41
+ }
42
+ if (!stopped) {
43
+ console.log(pc.yellow("CCS config daemon is not running"));
44
+ }
45
+ };
package/src/index.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Command } from "commander";
3
3
  import pkg from "../package.json";
4
+ import { ccsLogsCommand } from "./commands/ccs/logs.js";
5
+ import { ccsStartCommand } from "./commands/ccs/start.js";
6
+ import { ccsStatusCommand } from "./commands/ccs/status.js";
7
+ import { ccsStopCommand } from "./commands/ccs/stop.js";
4
8
  import { completionsCommand } from "./commands/completions.js";
5
9
  import { configDumpCommand } from "./commands/config/dump.js";
6
10
  import { configLoadCommand } from "./commands/config/load.js";
@@ -177,6 +181,40 @@ const main = async (): Promise<void> => {
177
181
  .action(async (input, options) => {
178
182
  await configLoadCommand(input, options);
179
183
  });
184
+ const ccsCommandGroup = program
185
+ .command("ccs")
186
+ .description("CCS daemon management");
187
+ ccsCommandGroup
188
+ .command("start")
189
+ .description("Start CCS config as background daemon")
190
+ .option("-f, --force", "Force restart if already running")
191
+ .action(async (options) => {
192
+ await ccsStartCommand(options);
193
+ });
194
+ ccsCommandGroup
195
+ .command("stop")
196
+ .description("Stop the CCS config daemon")
197
+ .option("-f, --force", "Force kill (SIGKILL)")
198
+ .action(async (options) => {
199
+ await ccsStopCommand(options);
200
+ });
201
+ ccsCommandGroup
202
+ .command("status")
203
+ .description("Check CCS config daemon status")
204
+ .action(async () => {
205
+ await ccsStatusCommand();
206
+ });
207
+ ccsCommandGroup
208
+ .command("logs")
209
+ .description("View CCS config daemon logs")
210
+ .option("-f, --follow", "Follow log output")
211
+ .option("-n, --lines <number>", "Number of lines", "50")
212
+ .action(async (options) => {
213
+ await ccsLogsCommand({
214
+ follow: options.follow,
215
+ lines: parseInt(options.lines, 10),
216
+ });
217
+ });
180
218
  try {
181
219
  await program.parseAsync(process.argv);
182
220
  } catch {
@@ -0,0 +1,153 @@
1
+ import { existsSync, mkdirSync, unlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ // Daemon directory inside CCS home
6
+ export const getDaemonDir = (): string => join(homedir(), ".ccs", "daemon");
7
+
8
+ // PID file path
9
+ export const getPidPath = (): string => join(getDaemonDir(), "ccs-config.pid");
10
+
11
+ // Log file path
12
+ export const getLogPath = (): string => join(getDaemonDir(), "ccs-config.log");
13
+
14
+ // Ensure daemon directory exists
15
+ export const ensureDaemonDir = (): void => {
16
+ const dir = getDaemonDir();
17
+ if (!existsSync(dir)) {
18
+ mkdirSync(dir, { recursive: true });
19
+ }
20
+ };
21
+
22
+ // Read PID from file
23
+ export const readPid = async (): Promise<number | null> => {
24
+ const pidPath = getPidPath();
25
+ if (!existsSync(pidPath)) {
26
+ return null;
27
+ }
28
+ try {
29
+ const content = await Bun.file(pidPath).text();
30
+ const pid = parseInt(content.trim(), 10);
31
+ return Number.isFinite(pid) ? pid : null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ };
36
+
37
+ // Write PID to file
38
+ export const writePid = async (pid: number): Promise<void> => {
39
+ ensureDaemonDir();
40
+ await Bun.write(getPidPath(), String(pid));
41
+ };
42
+
43
+ // Remove PID file
44
+ export const removePid = (): void => {
45
+ const pidPath = getPidPath();
46
+ if (existsSync(pidPath)) {
47
+ unlinkSync(pidPath);
48
+ }
49
+ };
50
+
51
+ // Check if process is running using kill(pid, 0)
52
+ export const isProcessRunning = (pid: number): boolean => {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch (error) {
57
+ // EPERM = permission denied (but process exists)
58
+ if ((error as NodeJS.ErrnoException).code === "EPERM") {
59
+ return true;
60
+ }
61
+ return false;
62
+ }
63
+ };
64
+
65
+ // Get running daemon PID (validates process is actually running)
66
+ export const getRunningDaemonPid = async (): Promise<number | null> => {
67
+ const pid = await readPid();
68
+ if (pid === null) {
69
+ return null;
70
+ }
71
+ if (!isProcessRunning(pid)) {
72
+ // Stale PID file - clean it up
73
+ removePid();
74
+ return null;
75
+ }
76
+ return pid;
77
+ };
78
+
79
+ // Locate ccs executable
80
+ export const getCcsExecutable = (): string => {
81
+ // Use 'ccs' from PATH - it should be globally installed
82
+ return "ccs";
83
+ };
84
+
85
+ // Known CCS daemon ports
86
+ export const CCS_PORTS = [3000, 8317];
87
+
88
+ // Kill process tree (on Windows, kills all child processes)
89
+ export const killProcessTree = async (
90
+ pid: number,
91
+ force?: boolean,
92
+ ): Promise<boolean> => {
93
+ if (process.platform === "win32") {
94
+ const args = ["/PID", String(pid), "/T"];
95
+ if (force) args.push("/F");
96
+ const proc = Bun.spawn(["taskkill", ...args], {
97
+ stdout: "ignore",
98
+ stderr: "ignore",
99
+ });
100
+ await proc.exited;
101
+ return proc.exitCode === 0;
102
+ }
103
+ const signal = force ? "SIGKILL" : "SIGTERM";
104
+ try {
105
+ process.kill(pid, signal);
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ };
111
+
112
+ // Get PID of process listening on a port
113
+ export const getProcessByPort = async (
114
+ port: number,
115
+ ): Promise<number | null> => {
116
+ if (process.platform === "win32") {
117
+ const proc = Bun.spawn(["cmd", "/c", `netstat -ano | findstr :${port}`], {
118
+ stdout: "pipe",
119
+ stderr: "ignore",
120
+ });
121
+ const output = await new Response(proc.stdout).text();
122
+ await proc.exited;
123
+ const lines = output.trim().split("\n");
124
+ for (const line of lines) {
125
+ if (line.includes("LISTENING") || line.includes("ESTABLISHED")) {
126
+ const parts = line.trim().split(/\s+/);
127
+ const pid = Number.parseInt(parts[parts.length - 1], 10);
128
+ if (Number.isFinite(pid) && pid > 0) {
129
+ return pid;
130
+ }
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+ const proc = Bun.spawn(["lsof", "-ti", `:${port}`], {
136
+ stdout: "pipe",
137
+ stderr: "ignore",
138
+ });
139
+ const output = await new Response(proc.stdout).text();
140
+ await proc.exited;
141
+ const pid = Number.parseInt(output.trim(), 10);
142
+ return Number.isFinite(pid) ? pid : null;
143
+ };
144
+
145
+ // Kill process by port
146
+ export const killProcessByPort = async (
147
+ port: number,
148
+ force?: boolean,
149
+ ): Promise<boolean> => {
150
+ const pid = await getProcessByPort(port);
151
+ if (pid === null) return false;
152
+ return killProcessTree(pid, force);
153
+ };