🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@leynier/ccst

Package Overview
Dependencies
Maintainers
1
Versions
18
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@leynier/ccst - npm Package Compare versions

Comparing version
0.7.0
to
1.0.0
+88
src/scripts/watcher.ts
#!/usr/bin/env bun
import { existsSync, watch } from "node:fs";
import { ccsDir, performCcsImport } from "../commands/import-profiles/ccs.js";
import { ContextManager } from "../core/context-manager.js";
import { getPaths } from "../utils/paths.js";
const DEBOUNCE_MS = 1000;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let isImporting = false;
const log = (message: string): void => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${message}`);
};
const runImport = async (): Promise<void> => {
if (isImporting) {
log("Import already in progress, skipping");
return;
}
isImporting = true;
try {
const manager = new ContextManager(getPaths("user"));
const result = await performCcsImport(manager);
if (result.importedCount > 0) {
log(
`Imported ${result.importedCount} profiles: ${result.profileNames.join(", ")}`,
);
} else {
log("No profiles to import");
}
} catch (error) {
log(`Import error: ${error}`);
} finally {
isImporting = false;
}
};
const handleFileChange = (eventType: string, filename: string | null): void => {
if (!filename || !filename.endsWith(".settings.json")) {
return;
}
log(`Detected ${eventType} on ${filename}`);
// Debounce
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(runImport, DEBOUNCE_MS);
};
const main = async (): Promise<void> => {
const ccsPath = ccsDir();
log(`CCS Settings Watcher - PID: ${process.pid}`);
log(`Watching directory: ${ccsPath}`);
if (!existsSync(ccsPath)) {
log(`ERROR: CCS directory not found: ${ccsPath}`);
log("Please run 'ccs setup' first to initialize CCS");
process.exit(1);
}
// Run initial import
log("Running initial import...");
await runImport();
// Start watching
const watcher = watch(ccsPath, { persistent: true }, handleFileChange);
// Graceful shutdown
const cleanup = (): void => {
log("Shutting down watcher");
if (debounceTimer) {
clearTimeout(debounceTimer);
}
watcher.close();
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
log("Watcher started successfully");
};
await main();
import { existsSync, openSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import {
ensureDaemonDir,
getDaemonDir,
isProcessRunning,
killProcessTree,
truncateFile,
} from "./daemon.js";
// Watcher PID file path
export const getWatcherPidPath = (): string =>
join(getDaemonDir(), "watcher.pid");
// Watcher log file path
export const getWatcherLogPath = (): string =>
join(getDaemonDir(), "watcher.log");
// Read watcher PID from file
export const readWatcherPid = async (): Promise<number | null> => {
const pidPath = getWatcherPidPath();
if (!existsSync(pidPath)) {
return null;
}
try {
const content = await Bun.file(pidPath).text();
const pid = parseInt(content.trim(), 10);
return Number.isFinite(pid) ? pid : null;
} catch {
return null;
}
};
// Write watcher PID to file
export const writeWatcherPid = async (pid: number): Promise<void> => {
ensureDaemonDir();
await Bun.write(getWatcherPidPath(), String(pid));
};
// Remove watcher PID file
export const removeWatcherPid = (): void => {
const pidPath = getWatcherPidPath();
if (existsSync(pidPath)) {
unlinkSync(pidPath);
}
};
// Get running watcher PID (validates process is actually running)
export const getRunningWatcherPid = async (): Promise<number | null> => {
const pid = await readWatcherPid();
if (pid === null) {
return null;
}
if (!isProcessRunning(pid)) {
// Stale PID file - clean it up
removeWatcherPid();
return null;
}
return pid;
};
// Get watcher script path
const getWatcherScriptPath = (): string => {
// The script is in src/scripts/watcher.ts relative to the package
// When running as installed package, we need to find it
const scriptPath = join(import.meta.dir, "..", "scripts", "watcher.ts");
return scriptPath;
};
// Start watcher process
export const startWatcher = async (): Promise<number | null> => {
const existingPid = await getRunningWatcherPid();
if (existingPid !== null) {
return existingPid;
}
ensureDaemonDir();
const logPath = getWatcherLogPath();
const scriptPath = getWatcherScriptPath();
// Truncate log file
await truncateFile(logPath);
if (process.platform === "win32") {
return startWatcherWindows(scriptPath, logPath);
}
return startWatcherUnix(scriptPath, logPath);
};
// Start watcher on Windows using VBScript to hide console
const startWatcherWindows = async (
scriptPath: string,
logPath: string,
): Promise<number | null> => {
const vbsPath = join(getDaemonDir(), "start-watcher.vbs");
const vbsContent = `
Set WshShell = CreateObject("WScript.Shell")
WshShell.Run "cmd /c bun run ""${scriptPath}"" >> ""${logPath}"" 2>&1", 0, False
`.trim();
await Bun.write(vbsPath, vbsContent);
const proc = Bun.spawn(["wscript", "//Nologo", vbsPath], {
detached: true,
stdio: ["ignore", "ignore", "ignore"],
});
proc.unref();
// Clean up VBS file after short delay
setTimeout(() => {
try {
unlinkSync(vbsPath);
} catch {
// Ignore cleanup errors
}
}, 1000);
// Poll for log file to have content (indicating process started)
const maxWait = 5000;
const interval = 200;
let waited = 0;
while (waited < maxWait) {
await new Promise((resolve) => setTimeout(resolve, interval));
waited += interval;
// Check if log file has content
if (existsSync(logPath)) {
const file = Bun.file(logPath);
const size = file.size;
if (size > 0) {
// Try to find the PID by reading the log
const content = await file.text();
const match = content.match(/PID:\s*(\d+)/);
if (match?.[1]) {
const pid = Number.parseInt(match[1], 10);
if (Number.isFinite(pid) && pid > 0) {
await writeWatcherPid(pid);
return pid;
}
}
}
}
}
return null;
};
// Start watcher on Unix
const startWatcherUnix = async (
scriptPath: string,
logPath: string,
): Promise<number | null> => {
const logFd = openSync(logPath, "a");
const child = Bun.spawn(["bun", "run", scriptPath], {
detached: true,
stdio: ["ignore", logFd, logFd],
});
child.unref();
const pid = child.pid;
if (pid) {
await writeWatcherPid(pid);
}
return pid;
};
// Stop watcher process
export const stopWatcher = async (force?: boolean): Promise<boolean> => {
const pid = await readWatcherPid();
if (pid === null) {
return false;
}
const killed = await killProcessTree(pid, force);
// Wait for process to terminate
const maxWait = force ? 1000 : 5000;
const interval = 100;
let waited = 0;
while (waited < maxWait && isProcessRunning(pid)) {
await new Promise((resolve) => setTimeout(resolve, interval));
waited += interval;
}
removeWatcherPid();
return killed || !isProcessRunning(pid);
};
+44
-82

@@ -1,105 +0,67 @@

Default to using Bun instead of Node.js.
# CLAUDE.md
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## APIs
## Build & Development Commands
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
```bash
# Install dependencies
bun install
## Testing
# Format code
bun run format
Use `bun test` to run tests.
# Lint code (with auto-fix)
bun run lint
```ts#index.test.ts
import { test, expect } from "bun:test";
# Run both format and lint
bun run validate
test("hello world", () => {
expect(1).toBe(1);
});
```
# Run tests
bun test
## Frontend
# Run a single test file
bun test src/core/context-manager.test.ts
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
# Run CLI locally during development
bun src/index.ts [args]
```
Server:
## Architecture Overview
```ts#index.ts
import index from "./index.html"
**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.
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
### Core Components
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.
- **`src/index.ts`** - Main CLI entry point using Commander.js. Defines all commands and routes to handlers.
- **`src/core/context-manager.ts`** - Central class for all context operations (CRUD, switching, merging). All operations flow through this class.
- **`src/core/merge-manager.ts`** - Handles permission merging with history tracking and smart deduplication.
- **`src/utils/daemon.ts`** - Cross-platform daemon process management (Windows/Unix). Critical for CCS daemon commands.
- **`src/utils/paths.ts`** - Resolves paths for all three settings levels (user/project/local).
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
### Settings Levels
With the following `frontend.tsx`:
The tool operates at three hierarchical levels:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
- **User:** `~/.claude/settings.json` and `~/.claude/settings/`
- **Project:** `./.claude/settings.json` and `./.claude/settings/`
- **Local:** `./.claude/settings.local.json` and `./.claude/settings/`
// import .css files directly and it works
import './index.css';
### Command Structure
const root = createRoot(document.body);
Commands in `src/commands/` are organized by function:
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
- `ccs/` - CCS daemon management (start, stop, status, logs, setup, install)
- `config/` - Configuration backup/restore (dump, load)
- `import-profiles/` - Profile importers (ccs, configs)
root.render(<Frontend />);
```
## Development Guidelines
Then, run index.ts
- **Use Bun, not Node.js** - All file operations use `Bun.file()`, `Bun.write()`, `Bun.remove()`. See `AGENTS.md` for Bun API patterns.
- **Use `Bun.$` for shell commands** - Not execa or child_process.
- **Cross-platform handling** - `src/utils/daemon.ts` has separate code paths for Windows (taskkill, netstat) and Unix (lsof, kill signals). Test both when modifying.
- **Formatting/Linting** - Uses Biome. Run `bun run validate` before committing.
```sh
bun --hot ./index.ts
```
## Commit Message Pattern
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
Follow existing commit prefixes: `feat:`, `fix:`, `chore:`
{
"name": "@leynier/ccst",
"version": "0.7.0",
"version": "1.0.0",
"description": "Claude Code Switch Tools for managing contexts",

@@ -49,2 +49,3 @@ "keywords": [

"index.ts",
"readme.md",
"README.md",

@@ -61,2 +62,2 @@ "LICENSE",

}
}
}
+112
-0

@@ -188,2 +188,81 @@ # ccst - Claude Code Switch Tools

## CCS Daemon Management
ccst can manage the CCS (Claude Code Server) daemon for background processing:
### Installation & Setup
```bash
# Install CCS CLI tool (interactive package manager selection)
ccst ccs install
# Run initial setup
ccst ccs setup
# Force setup even if already configured
ccst ccs setup -f
```
### Starting & Stopping
```bash
# Start daemon
ccst ccs start
# Start with specific dashboard port
ccst ccs start -p 3001
# Force restart if already running
ccst ccs start -f
# Keep existing logs (append instead of truncate)
ccst ccs start --keep-logs
# Stop daemon
ccst ccs stop
# Force kill daemon
ccst ccs stop -f
```
### Monitoring
```bash
# Check daemon status
ccst ccs status
# View logs (last 50 lines by default)
ccst ccs logs
# View more lines
ccst ccs logs -n 100
# Follow logs in real-time
ccst ccs logs -f
```
## Configuration Backup
Backup and restore all CCS configuration:
```bash
# Export configuration to ZIP file
ccst config dump
# Export to custom path
ccst config dump my-backup.zip
# Import configuration from ZIP
ccst config load
# Import from custom path
ccst config load my-backup.zip
# Replace all existing files during import
ccst config load -r
# Skip confirmation prompt
ccst config load -y
```
## File Structure

@@ -217,2 +296,11 @@

CCS daemon files (`~/.ccs/`):
```text
~/.ccs/
├── .daemon.pid # Daemon process ID
├── .daemon.log # Daemon log file
└── .daemon.ports # Dashboard port tracking
```
## Interactive Mode

@@ -316,2 +404,25 @@

### CCS Daemon Commands
- `ccst ccs install` - Install CCS CLI tool (interactive package manager selection)
- `ccst ccs setup` - Run CCS initial setup
- `ccst ccs setup -f` - Force setup even if already configured
- `ccst ccs start` - Start CCS daemon
- `ccst ccs start -f` - Force restart if already running
- `ccst ccs start -p <port>` - Start with specific dashboard port
- `ccst ccs start --keep-logs` - Keep existing logs (append instead of truncate)
- `ccst ccs stop` - Stop CCS daemon
- `ccst ccs stop -f` - Force kill daemon (SIGKILL)
- `ccst ccs status` - Show daemon status, PID, and log info
- `ccst ccs logs` - View daemon logs (last 50 lines)
- `ccst ccs logs -n <lines>` - View specified number of lines
- `ccst ccs logs -f` - Follow log output in real-time
### Configuration Commands
- `ccst config dump [output]` - Export CCS config to ZIP (default: ccs-config.zip)
- `ccst config load [input]` - Import CCS config from ZIP (default: ccs-config.zip)
- `ccst config load -r` - Replace all existing files during import
- `ccst config load -y` - Skip confirmation prompt
### Other Options

@@ -321,2 +432,3 @@

- `ccst --help` - Show help information
- `ccst --version` - Show version

@@ -323,0 +435,0 @@ ## Compatibility Note

@@ -20,2 +20,7 @@ import { spawn } from "node:child_process";

} from "../../utils/daemon.js";
import {
getRunningWatcherPid,
startWatcher,
stopWatcher,
} from "../../utils/watcher-daemon.js";

@@ -26,2 +31,4 @@ export type StartOptions = {

port?: number;
noWatch?: boolean;
timeout?: number;
};

@@ -45,2 +52,7 @@

await killProcessTree(existingPid, true);
// Also stop watcher if running
const watcherPid = await getRunningWatcherPid();
if (watcherPid !== null) {
await stopWatcher(true);
}
// Wait for process to terminate

@@ -115,3 +127,3 @@ const maxWait = 3000;

const maxWaitMs = 15000; // 15 seconds max
const maxWaitMs = options?.timeout ?? 30000;
const pollIntervalMs = 500;

@@ -152,4 +164,15 @@ const startTime = Date.now();

console.log(pc.dim(`Logs: ${logPath}`));
// Start file watcher unless --no-watch is specified
if (!options?.noWatch) {
const watcherPid = await startWatcher();
if (watcherPid) {
console.log(pc.dim(`File watcher started (PID: ${watcherPid})`));
} else {
console.log(pc.yellow("Warning: Failed to start file watcher"));
}
}
console.log(pc.dim("Run 'ccst ccs status' to check status"));
console.log(pc.dim("Run 'ccst ccs logs' to view logs"));
};

@@ -8,35 +8,54 @@ import { existsSync, statSync } from "node:fs";

} from "../../utils/daemon.js";
import {
getRunningWatcherPid,
getWatcherLogPath,
} from "../../utils/watcher-daemon.js";
export const ccsStatusCommand = async (): Promise<void> => {
// Show daemon status
const pid = await getRunningDaemonPid();
if (pid === null) {
console.log(pc.yellow("CCS config daemon is not running"));
return;
}
console.log(pc.green(`CCS config daemon is running (PID: ${pid})`));
// Show additional info
const pidPath = getPidPath();
const logPath = getLogPath();
console.log(pc.dim(`PID file: ${pidPath}`));
if (existsSync(logPath)) {
const stats = statSync(logPath);
const sizeKb = (stats.size / 1024).toFixed(2);
console.log(pc.dim(`Log file: ${logPath} (${sizeKb} KB)`));
}
// Try to get process uptime (Unix only)
if (process.platform !== "win32") {
try {
const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "etime="], {
stdout: "pipe",
stderr: "ignore",
});
const output = await new Response(proc.stdout).text();
const uptime = output.trim();
if (uptime) {
console.log(pc.dim(`Uptime: ${uptime}`));
} else {
console.log(pc.green(`CCS config daemon is running (PID: ${pid})`));
const pidPath = getPidPath();
const logPath = getLogPath();
console.log(pc.dim(`PID file: ${pidPath}`));
if (existsSync(logPath)) {
const stats = statSync(logPath);
const sizeKb = (stats.size / 1024).toFixed(2);
console.log(pc.dim(`Log file: ${logPath} (${sizeKb} KB)`));
}
// Try to get process uptime (Unix only)
if (process.platform !== "win32") {
try {
const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "etime="], {
stdout: "pipe",
stderr: "ignore",
});
const output = await new Response(proc.stdout).text();
const uptime = output.trim();
if (uptime) {
console.log(pc.dim(`Uptime: ${uptime}`));
}
} catch {
// ps command not available or failed
}
} catch {
// ps command not available or failed
}
}
// Show watcher status
console.log();
const watcherPid = await getRunningWatcherPid();
if (watcherPid !== null) {
console.log(pc.green(`File watcher is running (PID: ${watcherPid})`));
const watcherLogPath = getWatcherLogPath();
if (existsSync(watcherLogPath)) {
const stats = statSync(watcherLogPath);
const sizeKb = (stats.size / 1024).toFixed(2);
console.log(pc.dim(`Watcher log: ${watcherLogPath} (${sizeKb} KB)`));
}
} else {
console.log(pc.yellow("File watcher is not running"));
}
};

@@ -11,2 +11,6 @@ import pc from "picocolors";

} from "../../utils/daemon.js";
import {
getRunningWatcherPid,
stopWatcher,
} from "../../utils/watcher-daemon.js";

@@ -48,2 +52,12 @@ export type StopOptions = {

removePorts();
// Stop file watcher
const watcherPid = await getRunningWatcherPid();
if (watcherPid !== null) {
const watcherStopped = await stopWatcher(options?.force);
if (watcherStopped) {
console.log(pc.dim(`File watcher stopped (PID: ${watcherPid})`));
}
}
if (!stopped) {

@@ -50,0 +64,0 @@ console.log(pc.yellow("CCS config daemon is not running"));

@@ -10,3 +10,3 @@ import { existsSync, mkdirSync, readdirSync } from "node:fs";

const defaultConfigsDir = (): string => path.join(homedir(), ".ccst");
const ccsDir = (): string => path.join(homedir(), ".ccs");
export const ccsDir = (): string => path.join(homedir(), ".ccs");

@@ -53,5 +53,6 @@ const ensureDefaultConfig = async (

): Promise<void> => {
await manager.deleteContext(profileName).catch(() => undefined);
const input = `${JSON.stringify(merged, null, 2)}\n`;
await manager.importContextFromString(profileName, input);
// Write directly to the context file, overwriting if exists
const contextPath = path.join(manager.contextsDir, `${profileName}.json`);
const content = `${JSON.stringify(merged, null, 2)}\n`;
await Bun.write(contextPath, content);
};

@@ -70,6 +71,11 @@

export const importFromCcs = async (
export type PerformCcsImportResult = {
importedCount: number;
profileNames: string[];
};
export const performCcsImport = async (
manager: ContextManager,
configsDir?: string,
): Promise<void> => {
): Promise<PerformCcsImportResult> => {
const ccsPath = ccsDir();

@@ -79,3 +85,2 @@ if (!existsSync(ccsPath)) {

}
console.log(`📥 Importing profiles from CCS settings...`);
const dir = configsDir ?? defaultConfigsDir();

@@ -98,7 +103,6 @@ const { created } = await ensureDefaultConfig(manager, dir);

}
let importedCount = 0;
const profileNames: string[] = [];
for (const fileName of entries) {
const settingsPath = path.join(ccsPath, fileName);
const profileName = fileName.replace(/\.settings\.json$/u, "");
console.log(` 📦 Importing '${colors.cyan(profileName)}'...`);
const settings = await readJson<Record<string, unknown>>(settingsPath);

@@ -110,3 +114,3 @@ const merged = deepMerge(defaultConfig, settings);

await importProfile(manager, profileName, merged);
importedCount++;
profileNames.push(profileName);
}

@@ -116,5 +120,17 @@ if (currentContext) {

}
return { importedCount: profileNames.length, profileNames };
};
export const importFromCcs = async (
manager: ContextManager,
configsDir?: string,
): Promise<void> => {
console.log(`📥 Importing profiles from CCS settings...`);
const result = await performCcsImport(manager, configsDir);
for (const profileName of result.profileNames) {
console.log(` 📦 Imported '${colors.cyan(profileName)}'`);
}
console.log(
`✅ Imported ${colors.bold(colors.green(String(importedCount)))} profiles from CCS`,
`✅ Imported ${colors.bold(colors.green(String(result.importedCount)))} profiles from CCS`,
);
};

@@ -198,2 +198,8 @@ #!/usr/bin/env bun

)
.option("-W, --no-watch", "Skip file watcher")
.option(
"-t, --timeout <seconds>",
"Timeout in seconds for daemon startup (Windows only)",
"30",
)
.action(async (options) => {

@@ -204,2 +210,4 @@ await ccsStartCommand({

port: options.port ? Number.parseInt(options.port, 10) : undefined,
noWatch: options.watch === false,
timeout: Number.parseInt(options.timeout, 10) * 1000,
});

@@ -206,0 +214,0 @@ });