@leynier/ccst 0.5.3 → 0.7.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.5.3",
3
+ "version": "0.7.0",
4
4
  "description": "Claude Code Switch Tools for managing contexts",
5
5
  "keywords": [
6
6
  "claude",
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "commander": "^12.1.0",
35
+ "get-port": "^7.1.0",
35
36
  "jszip": "^3.10.1",
36
37
  "picocolors": "^1.1.0"
37
38
  },
@@ -0,0 +1,136 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import pc from "picocolors";
3
+ import { promptInput } from "../../utils/interactive.js";
4
+
5
+ type PackageManager = {
6
+ name: string;
7
+ displayCommand: string;
8
+ command: string;
9
+ args: string[];
10
+ };
11
+
12
+ const packageManagers: PackageManager[] = [
13
+ {
14
+ name: "bun",
15
+ displayCommand: "bun add -g @kaitranntt/ccs",
16
+ command: "bun",
17
+ args: ["add", "-g", "@kaitranntt/ccs"],
18
+ },
19
+ {
20
+ name: "npm",
21
+ displayCommand: "npm install -g @kaitranntt/ccs",
22
+ command: "npm",
23
+ args: ["install", "-g", "@kaitranntt/ccs"],
24
+ },
25
+ {
26
+ name: "pnpm",
27
+ displayCommand: "pnpm add -g @kaitranntt/ccs",
28
+ command: "pnpm",
29
+ args: ["add", "-g", "@kaitranntt/ccs"],
30
+ },
31
+ {
32
+ name: "yarn",
33
+ displayCommand: "yarn global add @kaitranntt/ccs",
34
+ command: "yarn",
35
+ args: ["global", "add", "@kaitranntt/ccs"],
36
+ },
37
+ ];
38
+
39
+ const selectPackageManager = async (): Promise<PackageManager | undefined> => {
40
+ const lines = packageManagers.map(
41
+ (pm, index) => `${index + 1}. ${pm.name} (${pm.displayCommand})`,
42
+ );
43
+ console.log("Select package manager to install @kaitranntt/ccs:");
44
+ console.log(lines.join("\n"));
45
+
46
+ const input = await promptInput("Select option (1-4)");
47
+ const index = Number.parseInt(input || "", 10);
48
+
49
+ if (!Number.isFinite(index) || index < 1 || index > packageManagers.length) {
50
+ console.log(pc.red("Invalid selection"));
51
+ return undefined;
52
+ }
53
+
54
+ return packageManagers[index - 1];
55
+ };
56
+
57
+ const verifyInstallation = async (): Promise<string | null> => {
58
+ const result = spawnSync("ccs", ["--version"], {
59
+ stdio: ["ignore", "pipe", "pipe"],
60
+ encoding: "utf8",
61
+ });
62
+
63
+ if (result.status !== 0) {
64
+ return null;
65
+ }
66
+
67
+ const version = result.stdout?.trim();
68
+ return version && version.length > 0 ? version : null;
69
+ };
70
+
71
+ const promptForSetup = async (): Promise<boolean> => {
72
+ const response = await promptInput("Run ccs setup now? (y/n)");
73
+ return response?.toLowerCase() === "y";
74
+ };
75
+
76
+ export const ccsInstallCommand = async (): Promise<void> => {
77
+ // Step 1: Select package manager
78
+ const selectedPm = await selectPackageManager();
79
+ if (!selectedPm) {
80
+ console.log(pc.dim("Installation cancelled"));
81
+ return;
82
+ }
83
+
84
+ // Step 2: Execute installation
85
+ console.log(
86
+ pc.dim(
87
+ `Installing @kaitranntt/ccs using ${selectedPm.name}... (this may take a moment)`,
88
+ ),
89
+ );
90
+ const installResult = spawnSync(selectedPm.command, selectedPm.args, {
91
+ stdio: "inherit",
92
+ });
93
+
94
+ if (installResult.status !== 0) {
95
+ console.log(
96
+ pc.red(
97
+ `Error: Installation failed with exit code ${installResult.status}`,
98
+ ),
99
+ );
100
+ return;
101
+ }
102
+
103
+ // Step 3: Verify installation
104
+ console.log(pc.dim("Verifying installation..."));
105
+ const version = await verifyInstallation();
106
+
107
+ if (!version) {
108
+ console.log(
109
+ pc.yellow("Warning: ccs installed but could not verify installation"),
110
+ );
111
+ console.log(pc.dim("You may need to restart your terminal"));
112
+ console.log(pc.dim("Try running 'which ccs' or 'ccs --version' manually"));
113
+ return;
114
+ }
115
+
116
+ console.log(pc.green(`ccs installed successfully (${version})`));
117
+
118
+ // Step 4: Ask if user wants to run setup
119
+ const shouldRunSetup = await promptForSetup();
120
+ if (shouldRunSetup) {
121
+ console.log(pc.dim("Running ccs setup..."));
122
+ const setupResult = spawnSync("ccs", ["setup"], { stdio: "inherit" });
123
+
124
+ if (setupResult.status === 0) {
125
+ console.log(pc.green("Setup completed successfully"));
126
+ } else {
127
+ console.log(
128
+ pc.yellow(
129
+ `Setup exited with code ${setupResult.status} (this may not be an error)`,
130
+ ),
131
+ );
132
+ }
133
+ } else {
134
+ console.log(pc.dim("You can run 'ccst ccs setup' later to configure ccs"));
135
+ }
136
+ };
@@ -0,0 +1,39 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import pc from "picocolors";
3
+
4
+ export type SetupOptions = {
5
+ force?: boolean;
6
+ };
7
+
8
+ export const ccsSetupCommand = async (
9
+ options?: SetupOptions,
10
+ ): Promise<void> => {
11
+ // Check if ccs is installed
12
+ const which = spawnSync("which", ["ccs"], { stdio: "ignore" });
13
+ if (which.status !== 0) {
14
+ console.log(pc.red("Error: ccs command not found"));
15
+ console.log(pc.dim("Run 'ccst ccs install' to install it"));
16
+ return;
17
+ }
18
+
19
+ // Build arguments
20
+ const args = ["setup"];
21
+ if (options?.force) {
22
+ args.push("--force");
23
+ }
24
+
25
+ // Execute ccs setup with real-time output
26
+ const result = spawnSync("ccs", args, {
27
+ stdio: "inherit",
28
+ });
29
+
30
+ // Check exit code
31
+ if (result.status !== 0) {
32
+ console.log(
33
+ pc.red(`Error: ccs setup failed with exit code ${result.status}`),
34
+ );
35
+ return;
36
+ }
37
+
38
+ console.log(pc.green("Setup completed successfully"));
39
+ };
@@ -1,19 +1,27 @@
1
1
  import { spawn } from "node:child_process";
2
- import { openSync } from "node:fs";
2
+ import { openSync, unlinkSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import getPort from "get-port";
3
6
  import pc from "picocolors";
4
7
  import {
5
8
  ensureDaemonDir,
6
9
  getCcsExecutable,
10
+ getCliproxyPort,
7
11
  getLogPath,
8
12
  getProcessByPort,
9
13
  getRunningDaemonPid,
10
14
  isProcessRunning,
11
15
  killProcessTree,
16
+ truncateFile,
12
17
  writePid,
18
+ writePorts,
13
19
  } from "../../utils/daemon.js";
14
20
 
15
21
  export type StartOptions = {
16
22
  force?: boolean;
23
+ keepLogs?: boolean;
24
+ port?: number;
17
25
  };
18
26
 
19
27
  export const ccsStartCommand = async (
@@ -44,28 +52,78 @@ export const ccsStartCommand = async (
44
52
  }
45
53
  ensureDaemonDir();
46
54
  const logPath = getLogPath();
55
+ if (!options?.keepLogs) {
56
+ try {
57
+ await truncateFile(logPath);
58
+ } catch {
59
+ console.warn(
60
+ pc.yellow(
61
+ `Warning: could not truncate log; continuing (logs will be appended): ${logPath}`,
62
+ ),
63
+ );
64
+ }
65
+ }
66
+ // Detect ports
67
+ const cliproxyPort = await getCliproxyPort();
68
+ const dashboardPort =
69
+ options?.port ??
70
+ (await getPort({
71
+ port: [3000, 3001, 3002, 8000, 8080],
72
+ }));
73
+ // Save ports for stop command
74
+ await writePorts({ dashboard: dashboardPort, cliproxy: cliproxyPort });
75
+ console.log(
76
+ pc.dim(
77
+ `Using dashboard port: ${dashboardPort}, CLIProxy port: ${cliproxyPort}`,
78
+ ),
79
+ );
47
80
  const ccsPath = getCcsExecutable();
48
81
  let pid: number | undefined;
49
82
  if (process.platform === "win32") {
50
- // On Windows, use cmd /c start /B to launch without creating a new window
51
- // This works with npm-installed .cmd wrappers that create their own console
52
- const proc = spawn("cmd", ["/c", `start /B "" "${ccsPath}" config`], {
83
+ // VBScript is the ONLY reliable way to run a process completely hidden on Windows
84
+ // WScript.Shell.Run with 0 = hidden window, False = don't wait
85
+ // Redirect output to log file using cmd /c with shell redirection
86
+ const escapedLogPath = logPath.replace(/\\/g, "\\\\");
87
+ const vbsContent = `CreateObject("WScript.Shell").Run "cmd /c ${ccsPath} config --port ${dashboardPort} >> ${escapedLogPath} 2>&1", 0, False`;
88
+ const vbsPath = join(tmpdir(), `ccs-start-${Date.now()}.vbs`);
89
+ await Bun.write(vbsPath, vbsContent);
90
+
91
+ // Run the vbs file (wscript itself doesn't show a window)
92
+ const proc = spawn("wscript", [vbsPath], {
93
+ detached: true,
53
94
  stdio: "ignore",
54
95
  windowsHide: true,
55
- detached: true,
56
96
  });
57
97
  proc.unref();
58
98
 
59
- // Wait for the process to start, then find it by port
60
- console.log(pc.dim("Starting CCS config daemon..."));
61
- await new Promise((resolve) => setTimeout(resolve, 2000));
99
+ // Clean up the vbs file after a short delay
100
+ setTimeout(() => {
101
+ try {
102
+ unlinkSync(vbsPath);
103
+ } catch {}
104
+ }, 1000);
105
+
106
+ // Poll for the port to become available
107
+ // ccs config takes ~6s to start (5s CLIProxy timeout + dashboard startup)
108
+ console.log(
109
+ pc.dim("Starting CCS config daemon (this may take a few seconds)..."),
110
+ );
111
+
112
+ const maxWaitMs = 15000; // 15 seconds max
113
+ const pollIntervalMs = 500;
114
+ const startTime = Date.now();
115
+ let foundPid: number | null = null;
116
+
117
+ while (Date.now() - startTime < maxWaitMs) {
118
+ foundPid = await getProcessByPort(dashboardPort);
119
+ if (foundPid !== null) break;
120
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
121
+ }
62
122
 
63
- // Find the process by port 3000 (dashboard port)
64
- const foundPid = await getProcessByPort(3000);
65
123
  if (foundPid === null) {
66
124
  console.log(pc.red("Failed to start CCS config daemon"));
67
125
  console.log(
68
- pc.dim("Check if ccs is installed: npm install -g @anthropic/ccs"),
126
+ pc.dim("Check if ccs is installed: npm install -g @kaitranntt/ccs"),
69
127
  );
70
128
  return;
71
129
  }
@@ -73,7 +131,7 @@ export const ccsStartCommand = async (
73
131
  } else {
74
132
  // On Unix, use regular spawn with detached mode
75
133
  const logFd = openSync(logPath, "a");
76
- const child = spawn(ccsPath, ["config"], {
134
+ const child = spawn(ccsPath, ["config", "--port", String(dashboardPort)], {
77
135
  detached: true,
78
136
  stdio: ["ignore", logFd, logFd],
79
137
  });
@@ -1,11 +1,12 @@
1
1
  import pc from "picocolors";
2
2
  import {
3
- CCS_PORTS,
3
+ getPortsToKill,
4
4
  getRunningDaemonPid,
5
5
  isProcessRunning,
6
6
  killProcessByPort,
7
7
  killProcessTree,
8
8
  removePid,
9
+ removePorts,
9
10
  } from "../../utils/daemon.js";
10
11
 
11
12
  export type StopOptions = {
@@ -32,13 +33,17 @@ export const ccsStopCommand = async (options?: StopOptions): Promise<void> => {
32
33
  stopped = true;
33
34
  }
34
35
  // Phase 2: Kill processes by port (especially important on Windows)
35
- for (const port of CCS_PORTS) {
36
+ // Use saved ports or fallback to defaults
37
+ const ports = await getPortsToKill();
38
+ for (const port of ports) {
36
39
  const killed = await killProcessByPort(port, options?.force ?? true);
37
40
  if (killed) {
38
41
  console.log(pc.dim(`Cleaned up process on port ${port}`));
39
42
  stopped = true;
40
43
  }
41
44
  }
45
+ // Clean up ports file
46
+ removePorts();
42
47
  if (!stopped) {
43
48
  console.log(pc.yellow("CCS config daemon is not running"));
44
49
  }
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Command } from "commander";
3
3
  import pkg from "../package.json";
4
+ import { ccsInstallCommand } from "./commands/ccs/install.js";
4
5
  import { ccsLogsCommand } from "./commands/ccs/logs.js";
6
+ import { ccsSetupCommand } from "./commands/ccs/setup.js";
5
7
  import { ccsStartCommand } from "./commands/ccs/start.js";
6
8
  import { ccsStatusCommand } from "./commands/ccs/status.js";
7
9
  import { ccsStopCommand } from "./commands/ccs/stop.js";
@@ -188,8 +190,17 @@ const main = async (): Promise<void> => {
188
190
  .command("start")
189
191
  .description("Start CCS config as background daemon")
190
192
  .option("-f, --force", "Force restart if already running")
193
+ .option("--keep-logs", "Keep existing log file (append)")
194
+ .option(
195
+ "-p, --port <number>",
196
+ "Dashboard port (auto-detect if not specified)",
197
+ )
191
198
  .action(async (options) => {
192
- await ccsStartCommand(options);
199
+ await ccsStartCommand({
200
+ force: options.force,
201
+ keepLogs: options.keepLogs,
202
+ port: options.port ? Number.parseInt(options.port, 10) : undefined,
203
+ });
193
204
  });
194
205
  ccsCommandGroup
195
206
  .command("stop")
@@ -215,6 +226,19 @@ const main = async (): Promise<void> => {
215
226
  lines: parseInt(options.lines, 10),
216
227
  });
217
228
  });
229
+ ccsCommandGroup
230
+ .command("setup")
231
+ .description("Run CCS initial setup")
232
+ .option("-f, --force", "Force setup even if already configured")
233
+ .action(async (options) => {
234
+ await ccsSetupCommand(options);
235
+ });
236
+ ccsCommandGroup
237
+ .command("install")
238
+ .description("Install CCS CLI tool")
239
+ .action(async () => {
240
+ await ccsInstallCommand();
241
+ });
218
242
  try {
219
243
  await program.parseAsync(process.argv);
220
244
  } catch {
@@ -11,6 +11,11 @@ export const getPidPath = (): string => join(getDaemonDir(), "ccs-config.pid");
11
11
  // Log file path
12
12
  export const getLogPath = (): string => join(getDaemonDir(), "ccs-config.log");
13
13
 
14
+ // Truncate a file (or create it empty if missing)
15
+ export const truncateFile = async (filePath: string): Promise<void> => {
16
+ await Bun.write(filePath, "");
17
+ };
18
+
14
19
  // Ensure daemon directory exists
15
20
  export const ensureDaemonDir = (): void => {
16
21
  const dir = getDaemonDir();
@@ -82,8 +87,84 @@ export const getCcsExecutable = (): string => {
82
87
  return "ccs";
83
88
  };
84
89
 
85
- // Known CCS daemon ports
86
- export const CCS_PORTS = [3000, 8317];
90
+ // Default CCS daemon ports (fallback)
91
+ export const DEFAULT_DASHBOARD_PORT = 3000;
92
+ export const DEFAULT_CLIPROXY_PORT = 8317;
93
+
94
+ // Ports file path
95
+ export const getPortsPath = (): string => join(getDaemonDir(), "ports.json");
96
+
97
+ // Type for daemon ports
98
+ export type DaemonPorts = {
99
+ dashboard: number;
100
+ cliproxy: number;
101
+ };
102
+
103
+ // Read saved ports
104
+ export const readPorts = async (): Promise<DaemonPorts | null> => {
105
+ const portsPath = getPortsPath();
106
+ if (!existsSync(portsPath)) {
107
+ return null;
108
+ }
109
+ try {
110
+ const content = await Bun.file(portsPath).text();
111
+ const ports = JSON.parse(content) as DaemonPorts;
112
+ if (
113
+ typeof ports.dashboard === "number" &&
114
+ typeof ports.cliproxy === "number"
115
+ ) {
116
+ return ports;
117
+ }
118
+ return null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ };
123
+
124
+ // Write ports to file
125
+ export const writePorts = async (ports: DaemonPorts): Promise<void> => {
126
+ ensureDaemonDir();
127
+ await Bun.write(getPortsPath(), JSON.stringify(ports, null, 2));
128
+ };
129
+
130
+ // Remove ports file
131
+ export const removePorts = (): void => {
132
+ const portsPath = getPortsPath();
133
+ if (existsSync(portsPath)) {
134
+ unlinkSync(portsPath);
135
+ }
136
+ };
137
+
138
+ // Read CLIProxy port from config.yaml
139
+ export const getCliproxyPort = async (): Promise<number> => {
140
+ const configPath = join(homedir(), ".ccs", "cliproxy", "config.yaml");
141
+ if (!existsSync(configPath)) {
142
+ return DEFAULT_CLIPROXY_PORT;
143
+ }
144
+ try {
145
+ const content = await Bun.file(configPath).text();
146
+ // Simple YAML parsing for "port: XXXX"
147
+ const match = content.match(/^port:\s*(\d+)/m);
148
+ if (match?.[1]) {
149
+ const port = Number.parseInt(match[1], 10);
150
+ if (Number.isFinite(port) && port > 0) {
151
+ return port;
152
+ }
153
+ }
154
+ return DEFAULT_CLIPROXY_PORT;
155
+ } catch {
156
+ return DEFAULT_CLIPROXY_PORT;
157
+ }
158
+ };
159
+
160
+ // Get ports to kill (from saved file or defaults)
161
+ export const getPortsToKill = async (): Promise<number[]> => {
162
+ const saved = await readPorts();
163
+ if (saved) {
164
+ return [saved.dashboard, saved.cliproxy];
165
+ }
166
+ return [DEFAULT_DASHBOARD_PORT, DEFAULT_CLIPROXY_PORT];
167
+ };
87
168
 
88
169
  // Kill process tree (on Windows, kills all child processes)
89
170
  export const killProcessTree = async (
@@ -71,6 +71,9 @@ const readStdinLine = async (): Promise<string> => {
71
71
  const cleanup = () => {
72
72
  process.stdin.off("data", onData);
73
73
  process.stdin.off("end", onEnd);
74
+ if (process.stdin.isTTY) {
75
+ process.stdin.pause();
76
+ }
74
77
  };
75
78
  if (process.stdin.isTTY) {
76
79
  process.stdin.resume();