@leynier/ccst 0.7.0 → 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.7.0",
3
+ "version": "1.0.0",
4
4
  "description": "Claude Code Switch Tools for managing contexts",
5
5
  "keywords": [
6
6
  "claude",
@@ -46,6 +46,7 @@
46
46
  "files": [
47
47
  "src",
48
48
  "index.ts",
49
+ "readme.md",
49
50
  "README.md",
50
51
  "LICENSE",
51
52
  "AGENTS.md",
@@ -57,4 +58,4 @@
57
58
  "publishConfig": {
58
59
  "access": "public"
59
60
  }
60
- }
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
 
@@ -17,11 +17,18 @@ import {
17
17
  writePid,
18
18
  writePorts,
19
19
  } from "../../utils/daemon.js";
20
+ import {
21
+ getRunningWatcherPid,
22
+ startWatcher,
23
+ stopWatcher,
24
+ } from "../../utils/watcher-daemon.js";
20
25
 
21
26
  export type StartOptions = {
22
27
  force?: boolean;
23
28
  keepLogs?: boolean;
24
29
  port?: number;
30
+ noWatch?: boolean;
31
+ timeout?: number;
25
32
  };
26
33
 
27
34
  export const ccsStartCommand = async (
@@ -40,6 +47,11 @@ export const ccsStartCommand = async (
40
47
  if (existingPid !== null && options?.force) {
41
48
  console.log(pc.dim(`Stopping existing daemon (PID: ${existingPid})...`));
42
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
+ }
43
55
  // Wait for process to terminate
44
56
  const maxWait = 3000;
45
57
  const startTime = Date.now();
@@ -109,7 +121,7 @@ export const ccsStartCommand = async (
109
121
  pc.dim("Starting CCS config daemon (this may take a few seconds)..."),
110
122
  );
111
123
 
112
- const maxWaitMs = 15000; // 15 seconds max
124
+ const maxWaitMs = options?.timeout ?? 30000;
113
125
  const pollIntervalMs = 500;
114
126
  const startTime = Date.now();
115
127
  let foundPid: number | null = null;
@@ -145,6 +157,17 @@ export const ccsStartCommand = async (
145
157
  await writePid(pid);
146
158
  console.log(pc.green(`CCS config daemon started (PID: ${pid})`));
147
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
+
148
171
  console.log(pc.dim("Run 'ccst ccs status' to check status"));
149
172
  console.log(pc.dim("Run 'ccst ccs logs' to view logs"));
150
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
  };
@@ -8,6 +8,10 @@ import {
8
8
  removePid,
9
9
  removePorts,
10
10
  } from "../../utils/daemon.js";
11
+ import {
12
+ getRunningWatcherPid,
13
+ stopWatcher,
14
+ } from "../../utils/watcher-daemon.js";
11
15
 
12
16
  export type StopOptions = {
13
17
  force?: boolean;
@@ -44,6 +48,16 @@ export const ccsStopCommand = async (options?: StopOptions): Promise<void> => {
44
48
  }
45
49
  // Clean up ports file
46
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
+
47
61
  if (!stopped) {
48
62
  console.log(pc.yellow("CCS config daemon is not running"));
49
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
@@ -195,11 +195,19 @@ const main = async (): Promise<void> => {
195
195
  "-p, --port <number>",
196
196
  "Dashboard port (auto-detect if not specified)",
197
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
+ )
198
204
  .action(async (options) => {
199
205
  await ccsStartCommand({
200
206
  force: options.force,
201
207
  keepLogs: options.keepLogs,
202
208
  port: options.port ? Number.parseInt(options.port, 10) : undefined,
209
+ noWatch: options.watch === false,
210
+ timeout: Number.parseInt(options.timeout, 10) * 1000,
203
211
  });
204
212
  });
205
213
  ccsCommandGroup
@@ -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();
@@ -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
+ };