@leynier/ccst 0.3.2 → 0.4.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.4.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,70 @@
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
+ writePid,
11
+ } from "../../utils/daemon.js";
12
+
13
+ export type StartOptions = {
14
+ force?: boolean;
15
+ };
16
+
17
+ export const ccsStartCommand = async (
18
+ options?: StartOptions,
19
+ ): Promise<void> => {
20
+ // Check if already running
21
+ const existingPid = await getRunningDaemonPid();
22
+ if (existingPid !== null && !options?.force) {
23
+ console.log(
24
+ pc.yellow(`CCS config daemon is already running (PID: ${existingPid})`),
25
+ );
26
+ console.log(pc.dim("Use --force to restart"));
27
+ return;
28
+ }
29
+ // If force and running, stop first
30
+ if (existingPid !== null && options?.force) {
31
+ console.log(pc.dim(`Stopping existing daemon (PID: ${existingPid})...`));
32
+ try {
33
+ process.kill(existingPid, "SIGTERM");
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
+ } catch {
44
+ // Process may have already exited
45
+ }
46
+ }
47
+ ensureDaemonDir();
48
+ const logPath = getLogPath();
49
+ const ccsPath = getCcsExecutable();
50
+ // Open log file for writing (append mode)
51
+ const logFd = openSync(logPath, "a");
52
+ // Spawn detached process
53
+ const child = spawn(ccsPath, ["config"], {
54
+ detached: true,
55
+ stdio: ["ignore", logFd, logFd],
56
+ // On Windows, need shell: true for proper detachment
57
+ ...(process.platform === "win32" ? { shell: true } : {}),
58
+ });
59
+ if (!child.pid) {
60
+ console.log(pc.red("Failed to start CCS config daemon"));
61
+ return;
62
+ }
63
+ await writePid(child.pid);
64
+ // Unref to allow parent to exit independently
65
+ child.unref();
66
+ console.log(pc.green(`CCS config daemon started (PID: ${child.pid})`));
67
+ console.log(pc.dim(`Logs: ${logPath}`));
68
+ console.log(pc.dim("Run 'ccst ccs status' to check status"));
69
+ console.log(pc.dim("Run 'ccst ccs logs' to view logs"));
70
+ };
@@ -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,47 @@
1
+ import pc from "picocolors";
2
+ import {
3
+ getRunningDaemonPid,
4
+ isProcessRunning,
5
+ removePid,
6
+ } from "../../utils/daemon.js";
7
+
8
+ export type StopOptions = {
9
+ force?: boolean;
10
+ };
11
+
12
+ export const ccsStopCommand = async (options?: StopOptions): Promise<void> => {
13
+ const pid = await getRunningDaemonPid();
14
+ if (pid === null) {
15
+ console.log(pc.yellow("CCS config daemon is not running"));
16
+ return;
17
+ }
18
+ try {
19
+ // Send SIGTERM for graceful shutdown, SIGKILL if --force
20
+ const signal = options?.force ? "SIGKILL" : "SIGTERM";
21
+ process.kill(pid, signal);
22
+ // Wait for process to terminate (with timeout)
23
+ const maxWait = options?.force ? 1000 : 5000;
24
+ const startTime = Date.now();
25
+ while (Date.now() - startTime < maxWait) {
26
+ if (!isProcessRunning(pid)) {
27
+ break;
28
+ }
29
+ await new Promise((resolve) => setTimeout(resolve, 100));
30
+ }
31
+ removePid();
32
+ console.log(pc.green(`CCS config daemon stopped (PID: ${pid})`));
33
+ } catch (error) {
34
+ const err = error as NodeJS.ErrnoException;
35
+ if (err.code === "ESRCH") {
36
+ // Process doesn't exist - clean up stale PID file
37
+ removePid();
38
+ console.log(pc.yellow("Process not found, cleaned up stale PID file"));
39
+ } else if (err.code === "EPERM") {
40
+ console.log(
41
+ pc.red("Permission denied. Try running with elevated privileges."),
42
+ );
43
+ } else {
44
+ console.log(pc.red(`Failed to stop daemon: ${err.message}`));
45
+ }
46
+ }
47
+ };
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,83 @@
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
+ };