@leynier/ccst 0.5.3 → 1.0.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/AGENTS.md CHANGED
@@ -1,105 +1,67 @@
1
- Default to using Bun instead of Node.js.
1
+ # CLAUDE.md
2
2
 
3
- - Use `bun <file>` instead of `node <file>` or `ts-node <file>`
4
- - Use `bun test` instead of `jest` or `vitest`
5
- - Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
6
- - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
7
- - Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
8
- - Use `bunx <package> <command>` instead of `npx <package> <command>`
9
- - Bun automatically loads .env, so don't use dotenv.
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
10
4
 
11
- ## APIs
5
+ ## Build & Development Commands
12
6
 
13
- - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
14
- - `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
15
- - `Bun.redis` for Redis. Don't use `ioredis`.
16
- - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
17
- - `WebSocket` is built-in. Don't use `ws`.
18
- - Prefer `Bun.file` over `node:fs`'s readFile/writeFile
19
- - Bun.$`ls` instead of execa.
7
+ ```bash
8
+ # Install dependencies
9
+ bun install
20
10
 
21
- ## Testing
11
+ # Format code
12
+ bun run format
22
13
 
23
- Use `bun test` to run tests.
14
+ # Lint code (with auto-fix)
15
+ bun run lint
24
16
 
25
- ```ts#index.test.ts
26
- import { test, expect } from "bun:test";
17
+ # Run both format and lint
18
+ bun run validate
27
19
 
28
- test("hello world", () => {
29
- expect(1).toBe(1);
30
- });
31
- ```
20
+ # Run tests
21
+ bun test
22
+
23
+ # Run a single test file
24
+ bun test src/core/context-manager.test.ts
32
25
 
33
- ## Frontend
34
-
35
- Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
36
-
37
- Server:
38
-
39
- ```ts#index.ts
40
- import index from "./index.html"
41
-
42
- Bun.serve({
43
- routes: {
44
- "/": index,
45
- "/api/users/:id": {
46
- GET: (req) => {
47
- return new Response(JSON.stringify({ id: req.params.id }));
48
- },
49
- },
50
- },
51
- // optional websocket support
52
- websocket: {
53
- open: (ws) => {
54
- ws.send("Hello, world!");
55
- },
56
- message: (ws, message) => {
57
- ws.send(message);
58
- },
59
- close: (ws) => {
60
- // handle close
61
- }
62
- },
63
- development: {
64
- hmr: true,
65
- console: true,
66
- }
67
- })
26
+ # Run CLI locally during development
27
+ bun src/index.ts [args]
68
28
  ```
69
29
 
70
- HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
30
+ ## Architecture Overview
71
31
 
72
- ```html#index.html
73
- <html>
74
- <body>
75
- <h1>Hello, world!</h1>
76
- <script type="module" src="./frontend.tsx"></script>
77
- </body>
78
- </html>
79
- ```
32
+ **ccst** (Claude Code Switch Tools) is a CLI tool that manages Claude Code IDE contexts and configurations. It allows users to switch between different permission sets, environments, and settings at user, project, and local levels.
80
33
 
81
- With the following `frontend.tsx`:
34
+ ### Core Components
82
35
 
83
- ```tsx#frontend.tsx
84
- import React from "react";
85
- import { createRoot } from "react-dom/client";
36
+ - **`src/index.ts`** - Main CLI entry point using Commander.js. Defines all commands and routes to handlers.
37
+ - **`src/core/context-manager.ts`** - Central class for all context operations (CRUD, switching, merging). All operations flow through this class.
38
+ - **`src/core/merge-manager.ts`** - Handles permission merging with history tracking and smart deduplication.
39
+ - **`src/utils/daemon.ts`** - Cross-platform daemon process management (Windows/Unix). Critical for CCS daemon commands.
40
+ - **`src/utils/paths.ts`** - Resolves paths for all three settings levels (user/project/local).
86
41
 
87
- // import .css files directly and it works
88
- import './index.css';
42
+ ### Settings Levels
89
43
 
90
- const root = createRoot(document.body);
44
+ The tool operates at three hierarchical levels:
91
45
 
92
- export default function Frontend() {
93
- return <h1>Hello, world!</h1>;
94
- }
46
+ - **User:** `~/.claude/settings.json` and `~/.claude/settings/`
47
+ - **Project:** `./.claude/settings.json` and `./.claude/settings/`
48
+ - **Local:** `./.claude/settings.local.json` and `./.claude/settings/`
95
49
 
96
- root.render(<Frontend />);
97
- ```
50
+ ### Command Structure
98
51
 
99
- Then, run index.ts
52
+ Commands in `src/commands/` are organized by function:
100
53
 
101
- ```sh
102
- bun --hot ./index.ts
103
- ```
54
+ - `ccs/` - CCS daemon management (start, stop, status, logs, setup, install)
55
+ - `config/` - Configuration backup/restore (dump, load)
56
+ - `import-profiles/` - Profile importers (ccs, configs)
57
+
58
+ ## Development Guidelines
59
+
60
+ - **Use Bun, not Node.js** - All file operations use `Bun.file()`, `Bun.write()`, `Bun.remove()`. See `AGENTS.md` for Bun API patterns.
61
+ - **Use `Bun.$` for shell commands** - Not execa or child_process.
62
+ - **Cross-platform handling** - `src/utils/daemon.ts` has separate code paths for Windows (taskkill, netstat) and Unix (lsof, kill signals). Test both when modifying.
63
+ - **Formatting/Linting** - Uses Biome. Run `bun run validate` before committing.
64
+
65
+ ## Commit Message Pattern
104
66
 
105
- For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
67
+ Follow existing commit prefixes: `feat:`, `fix:`, `chore:`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leynier/ccst",
3
- "version": "0.5.3",
3
+ "version": "1.0.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
  },
@@ -45,6 +46,7 @@
45
46
  "files": [
46
47
  "src",
47
48
  "index.ts",
49
+ "readme.md",
48
50
  "README.md",
49
51
  "LICENSE",
50
52
  "AGENTS.md",
@@ -56,4 +58,4 @@
56
58
  "publishConfig": {
57
59
  "access": "public"
58
60
  }
59
- }
61
+ }
package/readme.md CHANGED
@@ -185,6 +185,85 @@ ccst import configs
185
185
  ccst import configs -d /path/to/configs
186
186
  ```
187
187
 
188
+ ## CCS Daemon Management
189
+
190
+ ccst can manage the CCS (Claude Code Server) daemon for background processing:
191
+
192
+ ### Installation & Setup
193
+
194
+ ```bash
195
+ # Install CCS CLI tool (interactive package manager selection)
196
+ ccst ccs install
197
+
198
+ # Run initial setup
199
+ ccst ccs setup
200
+
201
+ # Force setup even if already configured
202
+ ccst ccs setup -f
203
+ ```
204
+
205
+ ### Starting & Stopping
206
+
207
+ ```bash
208
+ # Start daemon
209
+ ccst ccs start
210
+
211
+ # Start with specific dashboard port
212
+ ccst ccs start -p 3001
213
+
214
+ # Force restart if already running
215
+ ccst ccs start -f
216
+
217
+ # Keep existing logs (append instead of truncate)
218
+ ccst ccs start --keep-logs
219
+
220
+ # Stop daemon
221
+ ccst ccs stop
222
+
223
+ # Force kill daemon
224
+ ccst ccs stop -f
225
+ ```
226
+
227
+ ### Monitoring
228
+
229
+ ```bash
230
+ # Check daemon status
231
+ ccst ccs status
232
+
233
+ # View logs (last 50 lines by default)
234
+ ccst ccs logs
235
+
236
+ # View more lines
237
+ ccst ccs logs -n 100
238
+
239
+ # Follow logs in real-time
240
+ ccst ccs logs -f
241
+ ```
242
+
243
+ ## Configuration Backup
244
+
245
+ Backup and restore all CCS configuration:
246
+
247
+ ```bash
248
+ # Export configuration to ZIP file
249
+ ccst config dump
250
+
251
+ # Export to custom path
252
+ ccst config dump my-backup.zip
253
+
254
+ # Import configuration from ZIP
255
+ ccst config load
256
+
257
+ # Import from custom path
258
+ ccst config load my-backup.zip
259
+
260
+ # Replace all existing files during import
261
+ ccst config load -r
262
+
263
+ # Skip confirmation prompt
264
+ ccst config load -y
265
+ ```
266
+
188
267
  ## File Structure
189
268
 
190
269
  Contexts are stored as individual JSON files at different levels:
@@ -213,6 +292,15 @@ Project level (`./.claude/`):
213
292
  └── .cctx-state.local.json # Local state
214
293
  ```
215
294
 
295
+ CCS daemon files (`~/.ccs/`):
296
+
297
+ ```text
298
+ ~/.ccs/
299
+ ├── .daemon.pid # Daemon process ID
300
+ ├── .daemon.log # Daemon log file
301
+ └── .daemon.ports # Dashboard port tracking
302
+ ```
303
+
216
304
  ## Interactive Mode
217
305
 
218
306
  When no arguments are provided, ccst can enter interactive mode:
@@ -311,10 +399,34 @@ ccst -c
311
399
  - `ccst --in-project` - Project-level contexts (`./.claude/settings.json`)
312
400
  - `ccst --local` - Local project contexts (`./.claude/settings.local.json`)
313
401
 
402
+ ### CCS Daemon Commands
403
+
404
+ - `ccst ccs install` - Install CCS CLI tool (interactive package manager selection)
405
+ - `ccst ccs setup` - Run CCS initial setup
406
+ - `ccst ccs setup -f` - Force setup even if already configured
407
+ - `ccst ccs start` - Start CCS daemon
408
+ - `ccst ccs start -f` - Force restart if already running
409
+ - `ccst ccs start -p <port>` - Start with specific dashboard port
410
+ - `ccst ccs start --keep-logs` - Keep existing logs (append instead of truncate)
411
+ - `ccst ccs stop` - Stop CCS daemon
412
+ - `ccst ccs stop -f` - Force kill daemon (SIGKILL)
413
+ - `ccst ccs status` - Show daemon status, PID, and log info
414
+ - `ccst ccs logs` - View daemon logs (last 50 lines)
415
+ - `ccst ccs logs -n <lines>` - View specified number of lines
416
+ - `ccst ccs logs -f` - Follow log output in real-time
417
+
418
+ ### Configuration Commands
419
+
420
+ - `ccst config dump [output]` - Export CCS config to ZIP (default: ccs-config.zip)
421
+ - `ccst config load [input]` - Import CCS config from ZIP (default: ccs-config.zip)
422
+ - `ccst config load -r` - Replace all existing files during import
423
+ - `ccst config load -y` - Skip confirmation prompt
424
+
314
425
  ### Other Options
315
426
 
316
427
  - `ccst --completions <shell>` - Generate shell completions
317
428
  - `ccst --help` - Show help information
429
+ - `ccst --version` - Show version
318
430
 
319
431
  ## Compatibility Note
320
432
 
@@ -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,34 @@
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";
20
+ import {
21
+ getRunningWatcherPid,
22
+ startWatcher,
23
+ stopWatcher,
24
+ } from "../../utils/watcher-daemon.js";
14
25
 
15
26
  export type StartOptions = {
16
27
  force?: boolean;
28
+ keepLogs?: boolean;
29
+ port?: number;
30
+ noWatch?: boolean;
31
+ timeout?: number;
17
32
  };
18
33
 
19
34
  export const ccsStartCommand = async (
@@ -32,6 +47,11 @@ export const ccsStartCommand = async (
32
47
  if (existingPid !== null && options?.force) {
33
48
  console.log(pc.dim(`Stopping existing daemon (PID: ${existingPid})...`));
34
49
  await killProcessTree(existingPid, true);
50
+ // Also stop watcher if running
51
+ const watcherPid = await getRunningWatcherPid();
52
+ if (watcherPid !== null) {
53
+ await stopWatcher(true);
54
+ }
35
55
  // Wait for process to terminate
36
56
  const maxWait = 3000;
37
57
  const startTime = Date.now();
@@ -44,28 +64,78 @@ export const ccsStartCommand = async (
44
64
  }
45
65
  ensureDaemonDir();
46
66
  const logPath = getLogPath();
67
+ if (!options?.keepLogs) {
68
+ try {
69
+ await truncateFile(logPath);
70
+ } catch {
71
+ console.warn(
72
+ pc.yellow(
73
+ `Warning: could not truncate log; continuing (logs will be appended): ${logPath}`,
74
+ ),
75
+ );
76
+ }
77
+ }
78
+ // Detect ports
79
+ const cliproxyPort = await getCliproxyPort();
80
+ const dashboardPort =
81
+ options?.port ??
82
+ (await getPort({
83
+ port: [3000, 3001, 3002, 8000, 8080],
84
+ }));
85
+ // Save ports for stop command
86
+ await writePorts({ dashboard: dashboardPort, cliproxy: cliproxyPort });
87
+ console.log(
88
+ pc.dim(
89
+ `Using dashboard port: ${dashboardPort}, CLIProxy port: ${cliproxyPort}`,
90
+ ),
91
+ );
47
92
  const ccsPath = getCcsExecutable();
48
93
  let pid: number | undefined;
49
94
  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`], {
95
+ // VBScript is the ONLY reliable way to run a process completely hidden on Windows
96
+ // WScript.Shell.Run with 0 = hidden window, False = don't wait
97
+ // Redirect output to log file using cmd /c with shell redirection
98
+ const escapedLogPath = logPath.replace(/\\/g, "\\\\");
99
+ const vbsContent = `CreateObject("WScript.Shell").Run "cmd /c ${ccsPath} config --port ${dashboardPort} >> ${escapedLogPath} 2>&1", 0, False`;
100
+ const vbsPath = join(tmpdir(), `ccs-start-${Date.now()}.vbs`);
101
+ await Bun.write(vbsPath, vbsContent);
102
+
103
+ // Run the vbs file (wscript itself doesn't show a window)
104
+ const proc = spawn("wscript", [vbsPath], {
105
+ detached: true,
53
106
  stdio: "ignore",
54
107
  windowsHide: true,
55
- detached: true,
56
108
  });
57
109
  proc.unref();
58
110
 
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));
111
+ // Clean up the vbs file after a short delay
112
+ setTimeout(() => {
113
+ try {
114
+ unlinkSync(vbsPath);
115
+ } catch {}
116
+ }, 1000);
117
+
118
+ // Poll for the port to become available
119
+ // ccs config takes ~6s to start (5s CLIProxy timeout + dashboard startup)
120
+ console.log(
121
+ pc.dim("Starting CCS config daemon (this may take a few seconds)..."),
122
+ );
123
+
124
+ const maxWaitMs = options?.timeout ?? 30000;
125
+ const pollIntervalMs = 500;
126
+ const startTime = Date.now();
127
+ let foundPid: number | null = null;
128
+
129
+ while (Date.now() - startTime < maxWaitMs) {
130
+ foundPid = await getProcessByPort(dashboardPort);
131
+ if (foundPid !== null) break;
132
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
133
+ }
62
134
 
63
- // Find the process by port 3000 (dashboard port)
64
- const foundPid = await getProcessByPort(3000);
65
135
  if (foundPid === null) {
66
136
  console.log(pc.red("Failed to start CCS config daemon"));
67
137
  console.log(
68
- pc.dim("Check if ccs is installed: npm install -g @anthropic/ccs"),
138
+ pc.dim("Check if ccs is installed: npm install -g @kaitranntt/ccs"),
69
139
  );
70
140
  return;
71
141
  }
@@ -73,7 +143,7 @@ export const ccsStartCommand = async (
73
143
  } else {
74
144
  // On Unix, use regular spawn with detached mode
75
145
  const logFd = openSync(logPath, "a");
76
- const child = spawn(ccsPath, ["config"], {
146
+ const child = spawn(ccsPath, ["config", "--port", String(dashboardPort)], {
77
147
  detached: true,
78
148
  stdio: ["ignore", logFd, logFd],
79
149
  });
@@ -87,6 +157,17 @@ export const ccsStartCommand = async (
87
157
  await writePid(pid);
88
158
  console.log(pc.green(`CCS config daemon started (PID: ${pid})`));
89
159
  console.log(pc.dim(`Logs: ${logPath}`));
160
+
161
+ // Start file watcher unless --no-watch is specified
162
+ if (!options?.noWatch) {
163
+ const watcherPid = await startWatcher();
164
+ if (watcherPid) {
165
+ console.log(pc.dim(`File watcher started (PID: ${watcherPid})`));
166
+ } else {
167
+ console.log(pc.yellow("Warning: Failed to start file watcher"));
168
+ }
169
+ }
170
+
90
171
  console.log(pc.dim("Run 'ccst ccs status' to check status"));
91
172
  console.log(pc.dim("Run 'ccst ccs logs' to view logs"));
92
173
  };
@@ -5,37 +5,56 @@ import {
5
5
  getPidPath,
6
6
  getRunningDaemonPid,
7
7
  } from "../../utils/daemon.js";
8
+ import {
9
+ getRunningWatcherPid,
10
+ getWatcherLogPath,
11
+ } from "../../utils/watcher-daemon.js";
8
12
 
9
13
  export const ccsStatusCommand = async (): Promise<void> => {
14
+ // Show daemon status
10
15
  const pid = await getRunningDaemonPid();
11
16
  if (pid === null) {
12
17
  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}`));
18
+ } else {
19
+ console.log(pc.green(`CCS config daemon is running (PID: ${pid})`));
20
+ const pidPath = getPidPath();
21
+ const logPath = getLogPath();
22
+ console.log(pc.dim(`PID file: ${pidPath}`));
23
+ if (existsSync(logPath)) {
24
+ const stats = statSync(logPath);
25
+ const sizeKb = (stats.size / 1024).toFixed(2);
26
+ console.log(pc.dim(`Log file: ${logPath} (${sizeKb} KB)`));
27
+ }
28
+ // Try to get process uptime (Unix only)
29
+ if (process.platform !== "win32") {
30
+ try {
31
+ const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "etime="], {
32
+ stdout: "pipe",
33
+ stderr: "ignore",
34
+ });
35
+ const output = await new Response(proc.stdout).text();
36
+ const uptime = output.trim();
37
+ if (uptime) {
38
+ console.log(pc.dim(`Uptime: ${uptime}`));
39
+ }
40
+ } catch {
41
+ // ps command not available or failed
36
42
  }
37
- } catch {
38
- // ps command not available or failed
39
43
  }
40
44
  }
45
+
46
+ // Show watcher status
47
+ console.log();
48
+ const watcherPid = await getRunningWatcherPid();
49
+ if (watcherPid !== null) {
50
+ console.log(pc.green(`File watcher is running (PID: ${watcherPid})`));
51
+ const watcherLogPath = getWatcherLogPath();
52
+ if (existsSync(watcherLogPath)) {
53
+ const stats = statSync(watcherLogPath);
54
+ const sizeKb = (stats.size / 1024).toFixed(2);
55
+ console.log(pc.dim(`Watcher log: ${watcherLogPath} (${sizeKb} KB)`));
56
+ }
57
+ } else {
58
+ console.log(pc.yellow("File watcher is not running"));
59
+ }
41
60
  };
@@ -1,12 +1,17 @@
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";
11
+ import {
12
+ getRunningWatcherPid,
13
+ stopWatcher,
14
+ } from "../../utils/watcher-daemon.js";
10
15
 
11
16
  export type StopOptions = {
12
17
  force?: boolean;
@@ -32,13 +37,27 @@ export const ccsStopCommand = async (options?: StopOptions): Promise<void> => {
32
37
  stopped = true;
33
38
  }
34
39
  // Phase 2: Kill processes by port (especially important on Windows)
35
- for (const port of CCS_PORTS) {
40
+ // Use saved ports or fallback to defaults
41
+ const ports = await getPortsToKill();
42
+ for (const port of ports) {
36
43
  const killed = await killProcessByPort(port, options?.force ?? true);
37
44
  if (killed) {
38
45
  console.log(pc.dim(`Cleaned up process on port ${port}`));
39
46
  stopped = true;
40
47
  }
41
48
  }
49
+ // Clean up ports file
50
+ removePorts();
51
+
52
+ // Stop file watcher
53
+ const watcherPid = await getRunningWatcherPid();
54
+ if (watcherPid !== null) {
55
+ const watcherStopped = await stopWatcher(options?.force);
56
+ if (watcherStopped) {
57
+ console.log(pc.dim(`File watcher stopped (PID: ${watcherPid})`));
58
+ }
59
+ }
60
+
42
61
  if (!stopped) {
43
62
  console.log(pc.yellow("CCS config daemon is not running"));
44
63
  }
@@ -7,7 +7,7 @@ import { deepMerge } from "../../utils/deep-merge.js";
7
7
  import { readJson, readJsonIfExists } from "../../utils/json.js";
8
8
 
9
9
  const defaultConfigsDir = (): string => path.join(homedir(), ".ccst");
10
- const ccsDir = (): string => path.join(homedir(), ".ccs");
10
+ export const ccsDir = (): string => path.join(homedir(), ".ccs");
11
11
 
12
12
  const ensureDefaultConfig = async (
13
13
  manager: ContextManager,
@@ -49,9 +49,10 @@ const importProfile = async (
49
49
  profileName: string,
50
50
  merged: Record<string, unknown>,
51
51
  ): Promise<void> => {
52
- await manager.deleteContext(profileName).catch(() => undefined);
53
- const input = `${JSON.stringify(merged, null, 2)}\n`;
54
- await manager.importContextFromString(profileName, input);
52
+ // Write directly to the context file, overwriting if exists
53
+ const contextPath = path.join(manager.contextsDir, `${profileName}.json`);
54
+ const content = `${JSON.stringify(merged, null, 2)}\n`;
55
+ await Bun.write(contextPath, content);
55
56
  };
56
57
 
57
58
  const createDefaultProfileIfNeeded = async (
@@ -65,15 +66,19 @@ const createDefaultProfileIfNeeded = async (
65
66
  await importProfile(manager, "default", defaultProfile);
66
67
  };
67
68
 
68
- export const importFromCcs = async (
69
+ export type PerformCcsImportResult = {
70
+ importedCount: number;
71
+ profileNames: string[];
72
+ };
73
+
74
+ export const performCcsImport = async (
69
75
  manager: ContextManager,
70
76
  configsDir?: string,
71
- ): Promise<void> => {
77
+ ): Promise<PerformCcsImportResult> => {
72
78
  const ccsPath = ccsDir();
73
79
  if (!existsSync(ccsPath)) {
74
80
  throw new Error(`CCS directory not found: ${ccsPath}`);
75
81
  }
76
- console.log(`📥 Importing profiles from CCS settings...`);
77
82
  const dir = configsDir ?? defaultConfigsDir();
78
83
  const { created } = await ensureDefaultConfig(manager, dir);
79
84
  const defaultConfig = await loadDefaultConfig(dir);
@@ -91,23 +96,34 @@ export const importFromCcs = async (
91
96
  } catch {
92
97
  entries = [];
93
98
  }
94
- let importedCount = 0;
99
+ const profileNames: string[] = [];
95
100
  for (const fileName of entries) {
96
101
  const settingsPath = path.join(ccsPath, fileName);
97
102
  const profileName = fileName.replace(/\.settings\.json$/u, "");
98
- console.log(` 📦 Importing '${colors.cyan(profileName)}'...`);
99
103
  const settings = await readJson<Record<string, unknown>>(settingsPath);
100
104
  const merged = deepMerge(defaultConfig, settings);
101
105
  if (currentContext && currentContext === profileName) {
102
106
  await manager.unsetContext();
103
107
  }
104
108
  await importProfile(manager, profileName, merged);
105
- importedCount++;
109
+ profileNames.push(profileName);
106
110
  }
107
111
  if (currentContext) {
108
112
  await manager.switchContext(currentContext);
109
113
  }
114
+ return { importedCount: profileNames.length, profileNames };
115
+ };
116
+
117
+ export const importFromCcs = async (
118
+ manager: ContextManager,
119
+ configsDir?: string,
120
+ ): Promise<void> => {
121
+ console.log(`📥 Importing profiles from CCS settings...`);
122
+ const result = await performCcsImport(manager, configsDir);
123
+ for (const profileName of result.profileNames) {
124
+ console.log(` 📦 Imported '${colors.cyan(profileName)}'`);
125
+ }
110
126
  console.log(
111
- `✅ Imported ${colors.bold(colors.green(String(importedCount)))} profiles from CCS`,
127
+ `✅ Imported ${colors.bold(colors.green(String(result.importedCount)))} profiles from CCS`,
112
128
  );
113
129
  };
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,25 @@ 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
+ )
198
+ .option("-W, --no-watch", "Skip file watcher")
199
+ .option(
200
+ "-t, --timeout <seconds>",
201
+ "Timeout in seconds for daemon startup (Windows only)",
202
+ "30",
203
+ )
191
204
  .action(async (options) => {
192
- await ccsStartCommand(options);
205
+ await ccsStartCommand({
206
+ force: options.force,
207
+ keepLogs: options.keepLogs,
208
+ port: options.port ? Number.parseInt(options.port, 10) : undefined,
209
+ noWatch: options.watch === false,
210
+ timeout: Number.parseInt(options.timeout, 10) * 1000,
211
+ });
193
212
  });
194
213
  ccsCommandGroup
195
214
  .command("stop")
@@ -215,6 +234,19 @@ const main = async (): Promise<void> => {
215
234
  lines: parseInt(options.lines, 10),
216
235
  });
217
236
  });
237
+ ccsCommandGroup
238
+ .command("setup")
239
+ .description("Run CCS initial setup")
240
+ .option("-f, --force", "Force setup even if already configured")
241
+ .action(async (options) => {
242
+ await ccsSetupCommand(options);
243
+ });
244
+ ccsCommandGroup
245
+ .command("install")
246
+ .description("Install CCS CLI tool")
247
+ .action(async () => {
248
+ await ccsInstallCommand();
249
+ });
218
250
  try {
219
251
  await program.parseAsync(process.argv);
220
252
  } catch {
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync, watch } from "node:fs";
3
+ import { ccsDir, performCcsImport } from "../commands/import-profiles/ccs.js";
4
+ import { ContextManager } from "../core/context-manager.js";
5
+ import { getPaths } from "../utils/paths.js";
6
+
7
+ const DEBOUNCE_MS = 1000;
8
+
9
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
10
+ let isImporting = false;
11
+
12
+ const log = (message: string): void => {
13
+ const timestamp = new Date().toISOString();
14
+ console.log(`[${timestamp}] ${message}`);
15
+ };
16
+
17
+ const runImport = async (): Promise<void> => {
18
+ if (isImporting) {
19
+ log("Import already in progress, skipping");
20
+ return;
21
+ }
22
+ isImporting = true;
23
+ try {
24
+ const manager = new ContextManager(getPaths("user"));
25
+ const result = await performCcsImport(manager);
26
+ if (result.importedCount > 0) {
27
+ log(
28
+ `Imported ${result.importedCount} profiles: ${result.profileNames.join(", ")}`,
29
+ );
30
+ } else {
31
+ log("No profiles to import");
32
+ }
33
+ } catch (error) {
34
+ log(`Import error: ${error}`);
35
+ } finally {
36
+ isImporting = false;
37
+ }
38
+ };
39
+
40
+ const handleFileChange = (eventType: string, filename: string | null): void => {
41
+ if (!filename || !filename.endsWith(".settings.json")) {
42
+ return;
43
+ }
44
+ log(`Detected ${eventType} on ${filename}`);
45
+
46
+ // Debounce
47
+ if (debounceTimer) {
48
+ clearTimeout(debounceTimer);
49
+ }
50
+ debounceTimer = setTimeout(runImport, DEBOUNCE_MS);
51
+ };
52
+
53
+ const main = async (): Promise<void> => {
54
+ const ccsPath = ccsDir();
55
+
56
+ log(`CCS Settings Watcher - PID: ${process.pid}`);
57
+ log(`Watching directory: ${ccsPath}`);
58
+
59
+ if (!existsSync(ccsPath)) {
60
+ log(`ERROR: CCS directory not found: ${ccsPath}`);
61
+ log("Please run 'ccs setup' first to initialize CCS");
62
+ process.exit(1);
63
+ }
64
+
65
+ // Run initial import
66
+ log("Running initial import...");
67
+ await runImport();
68
+
69
+ // Start watching
70
+ const watcher = watch(ccsPath, { persistent: true }, handleFileChange);
71
+
72
+ // Graceful shutdown
73
+ const cleanup = (): void => {
74
+ log("Shutting down watcher");
75
+ if (debounceTimer) {
76
+ clearTimeout(debounceTimer);
77
+ }
78
+ watcher.close();
79
+ process.exit(0);
80
+ };
81
+
82
+ process.on("SIGINT", cleanup);
83
+ process.on("SIGTERM", cleanup);
84
+
85
+ log("Watcher started successfully");
86
+ };
87
+
88
+ await main();
@@ -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();
@@ -0,0 +1,191 @@
1
+ import { existsSync, openSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ ensureDaemonDir,
5
+ getDaemonDir,
6
+ isProcessRunning,
7
+ killProcessTree,
8
+ truncateFile,
9
+ } from "./daemon.js";
10
+
11
+ // Watcher PID file path
12
+ export const getWatcherPidPath = (): string =>
13
+ join(getDaemonDir(), "watcher.pid");
14
+
15
+ // Watcher log file path
16
+ export const getWatcherLogPath = (): string =>
17
+ join(getDaemonDir(), "watcher.log");
18
+
19
+ // Read watcher PID from file
20
+ export const readWatcherPid = async (): Promise<number | null> => {
21
+ const pidPath = getWatcherPidPath();
22
+ if (!existsSync(pidPath)) {
23
+ return null;
24
+ }
25
+ try {
26
+ const content = await Bun.file(pidPath).text();
27
+ const pid = parseInt(content.trim(), 10);
28
+ return Number.isFinite(pid) ? pid : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ };
33
+
34
+ // Write watcher PID to file
35
+ export const writeWatcherPid = async (pid: number): Promise<void> => {
36
+ ensureDaemonDir();
37
+ await Bun.write(getWatcherPidPath(), String(pid));
38
+ };
39
+
40
+ // Remove watcher PID file
41
+ export const removeWatcherPid = (): void => {
42
+ const pidPath = getWatcherPidPath();
43
+ if (existsSync(pidPath)) {
44
+ unlinkSync(pidPath);
45
+ }
46
+ };
47
+
48
+ // Get running watcher PID (validates process is actually running)
49
+ export const getRunningWatcherPid = async (): Promise<number | null> => {
50
+ const pid = await readWatcherPid();
51
+ if (pid === null) {
52
+ return null;
53
+ }
54
+ if (!isProcessRunning(pid)) {
55
+ // Stale PID file - clean it up
56
+ removeWatcherPid();
57
+ return null;
58
+ }
59
+ return pid;
60
+ };
61
+
62
+ // Get watcher script path
63
+ const getWatcherScriptPath = (): string => {
64
+ // The script is in src/scripts/watcher.ts relative to the package
65
+ // When running as installed package, we need to find it
66
+ const scriptPath = join(import.meta.dir, "..", "scripts", "watcher.ts");
67
+ return scriptPath;
68
+ };
69
+
70
+ // Start watcher process
71
+ export const startWatcher = async (): Promise<number | null> => {
72
+ const existingPid = await getRunningWatcherPid();
73
+ if (existingPid !== null) {
74
+ return existingPid;
75
+ }
76
+
77
+ ensureDaemonDir();
78
+ const logPath = getWatcherLogPath();
79
+ const scriptPath = getWatcherScriptPath();
80
+
81
+ // Truncate log file
82
+ await truncateFile(logPath);
83
+
84
+ if (process.platform === "win32") {
85
+ return startWatcherWindows(scriptPath, logPath);
86
+ }
87
+ return startWatcherUnix(scriptPath, logPath);
88
+ };
89
+
90
+ // Start watcher on Windows using VBScript to hide console
91
+ const startWatcherWindows = async (
92
+ scriptPath: string,
93
+ logPath: string,
94
+ ): Promise<number | null> => {
95
+ const vbsPath = join(getDaemonDir(), "start-watcher.vbs");
96
+ const vbsContent = `
97
+ Set WshShell = CreateObject("WScript.Shell")
98
+ WshShell.Run "cmd /c bun run ""${scriptPath}"" >> ""${logPath}"" 2>&1", 0, False
99
+ `.trim();
100
+
101
+ await Bun.write(vbsPath, vbsContent);
102
+
103
+ const proc = Bun.spawn(["wscript", "//Nologo", vbsPath], {
104
+ detached: true,
105
+ stdio: ["ignore", "ignore", "ignore"],
106
+ });
107
+ proc.unref();
108
+
109
+ // Clean up VBS file after short delay
110
+ setTimeout(() => {
111
+ try {
112
+ unlinkSync(vbsPath);
113
+ } catch {
114
+ // Ignore cleanup errors
115
+ }
116
+ }, 1000);
117
+
118
+ // Poll for log file to have content (indicating process started)
119
+ const maxWait = 5000;
120
+ const interval = 200;
121
+ let waited = 0;
122
+
123
+ while (waited < maxWait) {
124
+ await new Promise((resolve) => setTimeout(resolve, interval));
125
+ waited += interval;
126
+
127
+ // Check if log file has content
128
+ if (existsSync(logPath)) {
129
+ const file = Bun.file(logPath);
130
+ const size = file.size;
131
+ if (size > 0) {
132
+ // Try to find the PID by reading the log
133
+ const content = await file.text();
134
+ const match = content.match(/PID:\s*(\d+)/);
135
+ if (match?.[1]) {
136
+ const pid = Number.parseInt(match[1], 10);
137
+ if (Number.isFinite(pid) && pid > 0) {
138
+ await writeWatcherPid(pid);
139
+ return pid;
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
145
+
146
+ return null;
147
+ };
148
+
149
+ // Start watcher on Unix
150
+ const startWatcherUnix = async (
151
+ scriptPath: string,
152
+ logPath: string,
153
+ ): Promise<number | null> => {
154
+ const logFd = openSync(logPath, "a");
155
+
156
+ const child = Bun.spawn(["bun", "run", scriptPath], {
157
+ detached: true,
158
+ stdio: ["ignore", logFd, logFd],
159
+ });
160
+ child.unref();
161
+
162
+ const pid = child.pid;
163
+ if (pid) {
164
+ await writeWatcherPid(pid);
165
+ }
166
+
167
+ return pid;
168
+ };
169
+
170
+ // Stop watcher process
171
+ export const stopWatcher = async (force?: boolean): Promise<boolean> => {
172
+ const pid = await readWatcherPid();
173
+ if (pid === null) {
174
+ return false;
175
+ }
176
+
177
+ const killed = await killProcessTree(pid, force);
178
+
179
+ // Wait for process to terminate
180
+ const maxWait = force ? 1000 : 5000;
181
+ const interval = 100;
182
+ let waited = 0;
183
+
184
+ while (waited < maxWait && isProcessRunning(pid)) {
185
+ await new Promise((resolve) => setTimeout(resolve, interval));
186
+ waited += interval;
187
+ }
188
+
189
+ removeWatcherPid();
190
+ return killed || !isProcessRunning(pid);
191
+ };