Merge pull request #131 from kaitranntt/dev

feat(release): CLIProxy session persistence and dashboard status widget
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2025-12-18 01:48:13 -05:00
commit 1f0e270fca
15 files changed
+735 -282

No files matched your search

+96 -229
View File
@@ -13,36 +13,60 @@ CLI wrapper for instant switching between multiple Claude accounts and alternati
- **DRY**: One source of truth (config.json)
- **CLI-First**: All features must have CLI interface
## Common Mistakes (AVOID)
| Mistake | Consequence | Correct Action |
|---------|-------------|----------------|
| Running `validate` without `format` first | format:check fails | Run `bun run format` BEFORE validate |
| Using `chore:` for dev→main PR | No npm release triggered | Use `feat:` or `fix:` prefix |
| Committing directly to `main` or `dev` | Bypasses CI/review | Always use PRs |
| Manual version bump or git tag | Conflicts with semantic-release | Let CI handle versioning |
| Forgetting `--help` update | CLI docs out of sync | Update src/ccs.ts, lib/ccs, lib/ccs.ps1 |
## Quality Gates (MANDATORY)
Quality gates MUST pass before committing. Run from project root.
Quality gates MUST pass before committing. **Both projects have identical workflow.**
### Main Project (src/)
### Pre-Commit Sequence (FOLLOW THIS ORDER)
```bash
# Full validation (REQUIRED before commit/PR)
bun run validate # typecheck + lint:fix + format:check + test:all
# Main project (from repo root)
bun run format # Step 1: Fix formatting
bun run lint:fix # Step 2: Fix lint issues
bun run validate # Step 3: Final check (must pass)
# Individual commands
bun run typecheck # tsc --noEmit
bun run lint:fix # eslint src/ --fix
bun run format:check # prettier --check src/
bun run test:all # bun test
# Fix issues
bun run lint:fix # Auto-fix lint issues
bun run format # Auto-fix formatting (prettier --write)
# UI project (if UI changed)
cd ui
bun run format # Step 1: Fix formatting
bun run lint:fix # Step 2: Fix lint issues
bun run validate # Step 3: Final check (must pass)
```
**ESLint rules (eslint.config.mjs) - ALL errors:**
**WHY THIS ORDER:**
- `validate` runs `format:check` which only VERIFIES—won't fix
- If format:check fails, you skipped step 1
- CI runs `validate` only (no auto-fix)—local must be clean
### What Validate Runs
| Project | Command | Runs |
|---------|---------|------|
| Main | `bun run validate` | typecheck + lint:fix + format:check + test:all |
| UI | `bun run validate` | typecheck + lint:fix + format:check |
### ESLint Rules (ALL errors)
| Rule | Level | Notes |
|------|-------|-------|
| `@typescript-eslint/no-unused-vars` | error | Ignore `_` prefix |
| `@typescript-eslint/no-explicit-any` | error | Use proper types or `unknown` |
| `@typescript-eslint/no-non-null-assertion` | error | No `!` assertions |
| `prefer-const`, `no-var`, `eqeqeq` | error | Code quality |
| `react-hooks/*` (UI only) | recommended | Hooks rules |
| `react-refresh/*` (UI only) | vite | Fast refresh |
### TypeScript Options (strict mode)
**TypeScript (tsconfig.json) - strict mode:**
| Option | Value | Notes |
|--------|-------|-------|
| `strict` | true | All strict flags enabled |
@@ -51,68 +75,12 @@ bun run format # Auto-fix formatting (prettier --write)
| `noImplicitReturns` | true | All paths must return |
| `noFallthroughCasesInSwitch` | true | Explicit case handling |
### UI Project (ui/)
```bash
cd ui
# Full validation (REQUIRED before commit/PR)
bun run validate # typecheck + lint:fix + format:check (no tests)
# Individual commands
bun run typecheck # tsc -b --noEmit
bun run lint:fix # eslint . --fix
bun run format:check # prettier --check src/
# Fix issues
bun run lint:fix # Auto-fix lint issues
bun run format # Auto-fix formatting
```
**ESLint rules (ui/eslint.config.js) - ALL errors:**
| Rule | Level | Notes |
|------|-------|-------|
| `@typescript-eslint/no-unused-vars` | error | Ignore `_` prefix |
| `@typescript-eslint/no-explicit-any` | error | Use proper types or `unknown` |
| `@typescript-eslint/no-non-null-assertion` | error | No `!` assertions |
| `prefer-const`, `no-var`, `eqeqeq` | error | Code quality |
| `react-hooks/*` | recommended | Hooks rules from plugin |
| `react-refresh/*` | vite | Fast refresh compatibility |
**TypeScript (ui/tsconfig.app.json) - strict mode:**
| Option | Value | Notes |
|--------|-------|-------|
| `strict` | true | All strict flags enabled |
| `noUnusedLocals` | true | No unused variables |
| `noUnusedParameters` | true | No unused params |
| `noFallthroughCasesInSwitch` | true | Explicit case handling |
| `erasableSyntaxOnly` | true | No runtime type constructs |
| `noUncheckedSideEffectImports` | true | Validate side-effect imports |
### Automatic Enforcement
- `prepublishOnly` / `prepack` runs `build:all` + `validate` + `sync-version.js`
- CI/CD runs `bun run validate` on every PR
- husky pre-commit hooks enforce conventional commits
### File Structure
```
src/ → TypeScript source (main project)
dist/ → Compiled JavaScript (npm package)
lib/ → Native shell scripts (bash, PowerShell)
ui/src/ → React components, hooks, pages
ui/src/components/ui/ → shadcn/ui components
dist/ui/ → Built UI bundle (served by Express)
```
### Development Server
```bash
bun run dev # Build + start config server (http://localhost:3000)
```
**IMPORTANT:** Use `bun run dev` at CCS root for always up-to-date code. Do NOT use `ccs config` during development as it uses the globally installed version.
## Critical Constraints (NEVER VIOLATE)
1. **NO EMOJIS** - ASCII only: [OK], [!], [X], [i]
@@ -122,6 +90,17 @@ bun run dev # Build + start config server (http://localhost:3000)
5. **CLI documentation** - ALL changes MUST update `--help` in src/ccs.ts, lib/ccs, lib/ccs.ps1
6. **Idempotent** - All install operations safe to run multiple times
## File Structure
```
src/ → TypeScript source (main project)
dist/ → Compiled JavaScript (npm package)
lib/ → Native shell scripts (bash, PowerShell)
ui/src/ → React components, hooks, pages
ui/src/components/ui/ → shadcn/ui components
dist/ui/ → Built UI bundle (served by Express)
```
## Key Technical Details
### Profile Mechanisms (Priority Order)
@@ -151,7 +130,7 @@ Symlinked from `~/.ccs/shared/`: commands/, skills/, agents/
Profile-specific: settings.json, sessions/, todolists/, logs/
Windows fallback: Copies if symlinks unavailable
## Code Standards (REQUIRED)
## Code Standards
### Architecture
- `lib/ccs`, `lib/ccs.ps1` - Bootstrap scripts (delegate to Node.js via npx)
@@ -168,9 +147,8 @@ Windows fallback: Copies if symlinks unavailable
### TypeScript (src/*.ts)
- Node.js 14+, Bun 1.0+, TypeScript 5.3, strict mode
- `child_process.spawn`, handle SIGINT/SIGTERM
- Run `bun run validate` before committing
### Terminal Output (ENFORCE)
### Terminal Output
- ASCII only: [OK], [!], [X], [i] (NO emojis)
- TTY detect before colors, respect NO_COLOR
- Box borders for errors: ╔═╗║╚╝
@@ -179,181 +157,79 @@ Windows fallback: Copies if symlinks unavailable
**ALL commits MUST follow conventional commit format. Non-compliant commits are rejected by husky.**
### Commit Format
### Format
```
<type>(<scope>): <description>
[optional body]
[optional footer]
```
### Commit Types (determines version bump)
### Types (determines version bump)
| Type | Version Bump | Use For |
|------|--------------|---------|
| `feat:` | MINOR | New features |
| `fix:` | PATCH | Bug fixes |
| `perf:` | PATCH | Performance improvements |
| `perf:` | PATCH | Performance |
| `feat!:` | MAJOR | Breaking changes |
| `docs:` | None | Documentation only |
| `style:` | None | Formatting, no code change |
| `refactor:` | None | Code restructure |
| `test:` | None | Adding tests |
| `chore:` | None | Maintenance |
| `ci:` | None | CI/CD changes |
| `build:` | None | Build system |
| `docs:`, `style:`, `refactor:`, `test:`, `chore:`, `ci:`, `build:` | None | Non-release |
### Examples
```bash
# Good - will be accepted
# Good
git commit -m "feat(cliproxy): add OAuth token refresh"
git commit -m "fix(doctor): handle missing config gracefully"
git commit -m "feat!: remove deprecated GLMT proxy" # BREAKING CHANGE
# Bad - will be REJECTED
# Bad - REJECTED
git commit -m "added new feature"
git commit -m "Fixed bug"
git commit -m "WIP"
```
## Branching Strategy (FOLLOW STRICTLY)
### Branch Hierarchy
## Branching Strategy
### Hierarchy
```
main (production) ← dev (integration) ← feat/* | fix/* | docs/*
↑ ↑
│ └── All development merges here FIRST
│
└── Only receives: (1) Tested code from dev, (2) Hotfixes
↑
└── hotfix/* (critical only, skips dev)
```
### Branch Types
| Branch | Purpose | Merges From | Releases To |
|--------|---------|-------------|-------------|
| `main` | Production-ready | `dev`, `hotfix/*` | npm `@latest` |
| `dev` | Integration/staging | `feat/*`, `fix/*`, `docs/*` | npm `@dev` |
| `feat/*` | New features | - | → `dev` |
| `fix/*` | Bug fixes | - | → `dev` |
| `docs/*` | Documentation | - | → `dev` |
| `hotfix/*` | Critical production fixes | - | → `main` directly |
### Branch Naming Convention
```
<type>/<short-description>
# Examples:
feat/oauth-token-refresh
fix/doctor-missing-config
docs/update-installation-guide
hotfix/critical-auth-bug
```
### Standard Development Workflow (Features/Fixes → Dev → Main)
### Standard Workflow
```bash
# 1. ALWAYS start from dev (integration branch)
git checkout dev
git pull origin dev
# 2. Create feature branch FROM DEV
git checkout dev && git pull origin dev
git checkout -b feat/my-feature
# 3. Make changes with conventional commits
git commit -m "feat(scope): add new feature"
git commit -m "test(scope): add unit tests"
# 4. Push and create PR to DEV (not main!)
# ... develop with conventional commits ...
git push -u origin feat/my-feature
gh pr create --base dev --title "feat(scope): add new feature"
# → Merge triggers npm @dev release for testing
# 5. After testing in dev, promote to main (MUST use feat: or fix: prefix!)
git checkout dev
gh pr create --base dev --title "feat(scope): description"
# After testing in @dev:
gh pr create --base main --title "feat(release): promote dev to main"
# → Merge triggers npm @latest release
# WARNING: Using "chore:" will NOT trigger a release!
# 6. Clean up
git checkout dev
git pull origin dev
git branch -d feat/my-feature
```
### Hotfix Workflow (Critical Production Fixes Only)
### Hotfix Workflow (Production Emergencies Only)
```bash
# 1. Start from main (production)
git checkout main
git pull origin main
# 2. Create hotfix branch
git checkout main && git pull origin main
git checkout -b hotfix/critical-bug
# 3. Fix and commit
git commit -m "fix: critical authentication bypass"
# 4. PR directly to main (skip dev)
gh pr create --base main --title "fix: critical authentication bypass"
# → Merge triggers immediate npm @latest release
# 5. Sync hotfix back to dev
git checkout dev
git pull origin dev
git merge main
git push origin dev
# 6. Clean up
git branch -d hotfix/critical-bug
```
### Keeping Dev in Sync with Main
```bash
# After any main release, sync to dev
git checkout dev
git pull origin dev
git merge main
git push origin dev
# ... fix ...
gh pr create --base main --title "fix: critical issue"
# Then sync: git checkout dev && git merge main && git push
```
### Rules
1. **NEVER commit directly to `main` or `dev`** - always use PRs
2. **ALWAYS create feature branches from `dev`** (not main)
3. **Only `hotfix/*` branches go directly to `main`**
4. **`dev` must always be up-to-date with `main`**
5. **Delete feature branches after merge**
6. **Test in `@dev` before promoting to `@latest`**
7. **dev→main PRs MUST use `feat:` or `fix:` prefix** - `chore:` won't trigger npm release
1. **NEVER** commit directly to `main` or `dev`
2. Feature branches from `dev`, hotfixes from `main`
3. dev→main PRs MUST use `feat:` or `fix:` (not `chore:`)
4. Delete branches after merge
## Automated Releases (DO NOT MANUALLY TAG)
**Releases are FULLY AUTOMATED via semantic-release. NEVER manually bump versions or create tags.**
### Release Channels
| Branch | npm Tag | When |
|--------|---------|------|
| `main` | `@latest` | Merge PR to main |
| `dev` | `@dev` | Push to dev branch |
### What CI Does Automatically
1. Analyzes commits since last release
2. Determines version bump from commit types
3. Updates `CHANGELOG.md`, `VERSION`, `package.json`, installers
4. Creates git tag
5. Publishes to npm
6. Creates GitHub release
**CI handles:** version bump, CHANGELOG.md, git tag, npm publish, GitHub release.
**NEVER DO:**
- `./scripts/bump-version.sh` (deprecated, emergency only)
- `git tag vX.Y.Z` (tags are auto-created)
- Manual `npm publish` (CI handles it)
- Commit directly to `main`
## Development Workflows
## Development
### Testing (REQUIRED before PR)
```bash
@@ -365,39 +241,30 @@ bun run test:unit # Unit tests
### Local Development
```bash
./scripts/dev-install.sh # Build, pack, install globally
rm -rf ~/.ccs # Clean environment
bun run dev # Build + start config server (http://localhost:3000)
./scripts/dev-install.sh # Build, pack, install globally
rm -rf ~/.ccs # Clean environment
```
## Development Tasks (FOLLOW STRICTLY)
**IMPORTANT:** Use `bun run dev` at CCS root for always up-to-date code. Do NOT use `ccs config` during development as it uses the globally installed version.
### New Feature Checklist
1. Verify YAGNI/KISS/DRY alignment - reject if doesn't align
2. Implement in TypeScript (`src/*.ts`)
3. **REQUIRED**: Update `--help` in src/ccs.ts, lib/ccs, lib/ccs.ps1
4. Add unit tests (`tests/unit/**/*.test.js`)
5. Run `bun run validate`
6. Update README.md if user-facing
7. **Commit with**: `git commit -m "feat(scope): description"`
## Pre-Commit Checklist
### Bug Fix Checklist
1. Add regression test first
2. Fix in TypeScript (or native scripts if bootstrap-related)
3. Run `bun run validate`
4. **Commit with**: `git commit -m "fix(scope): description"`
**Quality (BLOCKERS):**
- [ ] `bun run format` — formatting fixed
- [ ] `bun run validate` — all checks pass
- [ ] `cd ui && bun run format && bun run validate` — if UI changed
## Pre-PR Checklist (MANDATORY)
**Code:**
- [ ] Conventional commit format (`feat:`, `fix:`, etc.)
- [ ] `--help` updated (src/ccs.ts, lib/ccs, lib/ccs.ps1) — if CLI changed
- [ ] Tests added/updated — if behavior changed
- [ ] README.md updated — if user-facing
**Quality Gates:**
- [ ] `bun run validate` passes (main: typecheck + lint:fix + format:check + tests)
- [ ] `cd ui && bun run validate` passes (ui: typecheck + lint:fix + format:check)
**Code Quality:**
- [ ] All commits follow conventional format (`feat:`, `fix:`, etc.)
- [ ] `--help` updated and consistent across src/ccs.ts, lib/ccs, lib/ccs.ps1
**Standards:**
- [ ] ASCII only (NO emojis), NO_COLOR respected
- [ ] Idempotent install, concurrent sessions work, instance isolation maintained
- [ ] **DO NOT** manually bump version or create tags
- [ ] YAGNI/KISS/DRY alignment verified
- [ ] No manual version bump or tags
## Error Handling Principles
+1 -1
View File
@@ -1 +1 @@
6.2.1
6.2.1-dev.2
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "6.2.1",
"version": "6.2.1-dev.2",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+15 -50
View File
@@ -318,13 +318,11 @@ export async function execClaudeWithCLIProxy(
const existingProxy = getExistingProxy(cfg.port);
let proxy: ChildProcess | null = null;
let sessionId: string;
let isReusingProxy = false;
if (existingProxy) {
// Reuse existing proxy - another CCS session started it
log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`);
sessionId = registerSession(cfg.port, existingProxy.pid);
isReusingProxy = true;
console.log(
info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`)
);
@@ -363,26 +361,20 @@ export async function execClaudeWithCLIProxy(
}
// 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy)
// Use detached mode so proxy persists after terminal closes
const proxyArgs = ['--config', configPath];
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
proxy = spawn(binaryPath, proxyArgs, {
stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'],
detached: false,
stdio: ['ignore', 'ignore', 'ignore'],
detached: true, // Persist after parent terminal closes
});
// Forward proxy output in verbose mode
if (verbose) {
proxy.stdout?.on('data', (data: Buffer) => {
process.stderr.write(`[cliproxy-out] ${data.toString()}`);
});
proxy.stderr?.on('data', (data: Buffer) => {
process.stderr.write(`[cliproxy-err] ${data.toString()}`);
});
}
// Unref so parent process can exit independently
proxy.unref();
// Handle proxy errors
// Handle proxy errors (only fires if spawn itself fails)
proxy.on('error', (error) => {
console.error(fail(`CLIProxy spawn error: ${error.message}`));
});
@@ -478,25 +470,14 @@ export async function execClaudeWithCLIProxy(
});
}
// 8. Cleanup: unregister session when Claude exits, kill proxy only if last session
// 8. Cleanup: unregister session when Claude exits
// Proxy persists by default - use 'ccs cliproxy stop' to kill manually
claude.on('exit', (code, signal) => {
log(`Claude exited: code=${code}, signal=${signal}`);
// Unregister this session - returns true if we were the last session
const shouldKillProxy = unregisterSession(sessionId);
log(`Session ${sessionId} unregistered, shouldKillProxy=${shouldKillProxy}`);
if (shouldKillProxy && proxy) {
// We were the last session and we own the proxy - kill it
log('Last session, killing proxy');
proxy.kill('SIGTERM');
} else if (shouldKillProxy && isReusingProxy) {
// We were the last session but don't own the proxy process
// The proxy will be cleaned up as zombie on next session start
log('Last session but reusing proxy, proxy will be cleaned up later');
} else {
log(`Other sessions still active, keeping proxy running`);
}
// Unregister this session (proxy keeps running for persistence)
unregisterSession(sessionId);
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
if (signal) {
process.kill(process.pid, signal as NodeJS.Signals);
@@ -508,11 +489,8 @@ export async function execClaudeWithCLIProxy(
claude.on('error', (error) => {
console.error(fail(`Claude CLI error: ${error}`));
// Unregister and conditionally kill proxy
const shouldKillProxy = unregisterSession(sessionId);
if (shouldKillProxy && proxy) {
proxy.kill('SIGTERM');
}
// Unregister session, proxy keeps running
unregisterSession(sessionId);
process.exit(1);
});
@@ -520,26 +498,13 @@ export async function execClaudeWithCLIProxy(
const cleanup = () => {
log('Parent signal received, cleaning up');
// Unregister and conditionally kill proxy
const shouldKillProxy = unregisterSession(sessionId);
if (shouldKillProxy && proxy) {
proxy.kill('SIGTERM');
}
// Unregister session, proxy keeps running
unregisterSession(sessionId);
claude.kill('SIGTERM');
};
process.once('SIGTERM', cleanup);
process.once('SIGINT', cleanup);
// Handle proxy crash (only if we own the proxy)
if (proxy) {
proxy.on('exit', (code, signal) => {
if (code !== 0 && code !== null) {
log(`Proxy exited unexpectedly: code=${code}, signal=${signal}`);
// Don't kill Claude - it may have already exited
}
});
}
}
/**
+75
View File
@@ -224,3 +224,78 @@ export function cleanupOrphanedSessions(port: number): void {
deleteSessionLock();
}
}
/**
* Stop the CLIProxy process and clean up session lock.
* @returns Object with success status and details
*/
export function stopProxy(): {
stopped: boolean;
pid?: number;
sessionCount?: number;
error?: string;
} {
const lock = readSessionLock();
if (!lock) {
return { stopped: false, error: 'No active CLIProxy session found' };
}
// Check if proxy is running
if (!isProcessRunning(lock.pid)) {
deleteSessionLock();
return { stopped: false, error: 'CLIProxy was not running (cleaned up stale lock)' };
}
const sessionCount = lock.sessions.length;
const pid = lock.pid;
try {
// Kill the proxy process
process.kill(pid, 'SIGTERM');
// Clean up session lock
deleteSessionLock();
return { stopped: true, pid, sessionCount };
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'ESRCH') {
// Process already gone
deleteSessionLock();
return { stopped: false, error: 'CLIProxy process already terminated' };
}
return { stopped: false, pid, error: `Failed to stop: ${error.message}` };
}
}
/**
* Get proxy status information.
*/
export function getProxyStatus(): {
running: boolean;
port?: number;
pid?: number;
sessionCount?: number;
startedAt?: string;
} {
const lock = readSessionLock();
if (!lock) {
return { running: false };
}
// Verify proxy is still running
if (!isProcessRunning(lock.pid)) {
deleteSessionLock();
return { running: false };
}
return {
running: true,
port: lock.port,
pid: lock.pid,
sessionCount: lock.sessions.length,
startedAt: lock.startedAt,
};
}
+82
View File
@@ -60,6 +60,7 @@ import {
saveUnifiedConfig,
} from '../config/unified-config-loader';
import { isUnifiedConfigEnabled } from '../config/feature-flags';
import { stopProxy, getProxyStatus } from '../cliproxy/session-tracker';
// ============================================================================
// PROFILE MANAGEMENT
@@ -804,6 +805,20 @@ async function showHelp(): Promise<void> {
}
console.log('');
// Proxy Lifecycle Commands
console.log(subheader('Proxy Lifecycle:'));
const lifecycleCmds: [string, string][] = [
['status', 'Show running CLIProxy status'],
['stop', 'Stop running CLIProxy instance'],
];
const maxLifecycleLen = Math.max(...lifecycleCmds.map(([cmd]) => cmd.length));
for (const [cmd, desc] of lifecycleCmds) {
console.log(` ${color(cmd.padEnd(maxLifecycleLen + 2), 'command')} ${desc}`);
}
console.log('');
console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.'));
console.log('');
// Binary Commands
console.log(subheader('Binary Commands:'));
const binaryCmds: [string, string][] = [
@@ -1041,6 +1056,62 @@ async function installLatest(verbose: boolean): Promise<void> {
}
}
// ============================================================================
// PROXY LIFECYCLE COMMANDS
// ============================================================================
/**
* Handle 'ccs cliproxy stop' - Stop running CLIProxy instance
*/
async function handleStop(): Promise<void> {
await initUI();
console.log(header('Stop CLIProxy'));
console.log('');
const result = stopProxy();
if (result.stopped) {
console.log(ok(`CLIProxy stopped (PID ${result.pid})`));
if (result.sessionCount && result.sessionCount > 0) {
console.log(info(`${result.sessionCount} active session(s) were disconnected`));
}
} else {
console.log(warn(result.error || 'Failed to stop CLIProxy'));
}
console.log('');
}
/**
* Handle 'ccs cliproxy status' - Show running proxy status
*/
async function handleProxyStatus(): Promise<void> {
await initUI();
console.log(header('CLIProxy Status'));
console.log('');
const status = getProxyStatus();
if (status.running) {
console.log(` Status: ${color('Running', 'success')}`);
console.log(` PID: ${status.pid}`);
console.log(` Port: ${status.port}`);
console.log(` Sessions: ${status.sessionCount || 0} active`);
if (status.startedAt) {
const started = new Date(status.startedAt);
console.log(` Started: ${started.toLocaleString()}`);
}
console.log('');
console.log(dim('To stop: ccs cliproxy stop'));
} else {
console.log(` Status: ${color('Not running', 'warning')}`);
console.log('');
console.log(dim('CLIProxy starts automatically when you run ccs gemini, codex, etc.'));
}
console.log('');
}
// ============================================================================
// MAIN ROUTER
// ============================================================================
@@ -1074,6 +1145,17 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return;
}
// Handle proxy lifecycle commands
if (command === 'stop') {
await handleStop();
return;
}
if (command === 'status') {
await handleProxyStatus();
return;
}
// Handle --install <version>
const installIdx = args.indexOf('--install');
if (installIdx !== -1) {
+4 -1
View File
@@ -10,7 +10,7 @@ import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
export interface FileChangeEvent {
type: 'config-changed' | 'settings-changed' | 'profiles-changed';
type: 'config-changed' | 'settings-changed' | 'profiles-changed' | 'proxy-status-changed';
path: string;
timestamp: number;
}
@@ -25,6 +25,7 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher {
path.join(ccsDir, 'config.json'),
path.join(ccsDir, '*.settings.json'),
path.join(ccsDir, 'profiles.json'),
path.join(ccsDir, 'cliproxy', 'sessions.json'), // Proxy session tracking
],
{
persistent: true,
@@ -44,6 +45,8 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher {
type = 'config-changed';
} else if (basename === 'profiles.json') {
type = 'profiles-changed';
} else if (basename === 'sessions.json') {
type = 'proxy-status-changed';
} else {
type = 'settings-changed';
}
+52
View File
@@ -40,6 +40,8 @@ import {
} from '../cliproxy/account-manager';
import type { CLIProxyProvider } from '../cliproxy/types';
import { getClaudeEnvVars } from '../cliproxy/config-generator';
import { getProxyStatus as getProxyProcessStatus } from '../cliproxy/session-tracker';
import { ensureCliproxyService } from '../cliproxy/service-manager';
// Unified config imports
import {
hasUnifiedConfig,
@@ -1290,6 +1292,56 @@ apiRoutes.get('/cliproxy/status', async (_req: Request, res: Response): Promise<
}
});
/**
* GET /api/cliproxy/proxy-status - Get detailed proxy process status
* Returns: { running, port?, pid?, sessionCount?, startedAt? }
* Combines session tracker data with actual port check for accuracy
*/
apiRoutes.get('/cliproxy/proxy-status', async (_req: Request, res: Response): Promise<void> => {
try {
// First check session tracker for detailed info
const sessionStatus = getProxyProcessStatus();
// If session tracker says running, trust it
if (sessionStatus.running) {
res.json(sessionStatus);
return;
}
// Session tracker says not running, but proxy might be running without session tracking
// (e.g., started before session persistence was implemented)
const actuallyRunning = await isCliproxyRunning();
if (actuallyRunning) {
// Proxy running but no session lock - legacy/untracked instance
res.json({
running: true,
port: 8317, // Default port
sessionCount: 0, // Unknown sessions
// No pid/startedAt since we don't have session lock
});
} else {
res.json(sessionStatus);
}
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/cliproxy/proxy-start - Start the CLIProxy service
* Returns: { started, alreadyRunning, port, error? }
* Starts proxy in background if not already running
*/
apiRoutes.post('/cliproxy/proxy-start', async (_req: Request, res: Response): Promise<void> => {
try {
const result = await ensureCliproxyService();
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/cliproxy/models - Get available models from CLIProxyAPI
* Returns: { models: CliproxyModel[], byCategory: Record<string, CliproxyModel[]>, totalCount: number }
@@ -21,6 +21,8 @@ const {
getSessionCount,
hasActiveSessions,
cleanupOrphanedSessions,
stopProxy,
getProxyStatus,
} = require('../../../dist/cliproxy/session-tracker');
describe('Session Tracker', function () {
@@ -296,6 +298,97 @@ describe('Session Tracker', function () {
});
});
describe('stopProxy', function () {
it('should return error when no lock exists', function () {
const result = stopProxy();
assert.strictEqual(result.stopped, false);
assert.strictEqual(result.error, 'No active CLIProxy session found');
});
it('should cleanup stale lock when proxy is not running', function () {
// Create lock with dead PID
const lock = {
port: testPort,
pid: 999999999, // Very unlikely to exist
sessions: ['session1'],
startedAt: new Date().toISOString(),
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
const result = stopProxy();
assert.strictEqual(result.stopped, false);
assert.ok(result.error.includes('not running'));
assert.strictEqual(fs.existsSync(sessionLockPath), false);
});
it('should return pid and session count on success', function () {
// Register a session with current process
registerSession(testPort, process.pid);
// Note: We can't actually test killing our own process,
// but we can verify the structure is correct before it attempts kill
const status = getProxyStatus();
assert.strictEqual(status.running, true);
assert.strictEqual(status.pid, process.pid);
assert.strictEqual(status.sessionCount, 1);
});
});
describe('getProxyStatus', function () {
it('should return not running when no lock exists', function () {
const result = getProxyStatus();
assert.strictEqual(result.running, false);
assert.strictEqual(result.port, undefined);
assert.strictEqual(result.pid, undefined);
});
it('should return full status when proxy is running', function () {
const startedAt = new Date().toISOString();
const lock = {
port: testPort,
pid: process.pid, // Current process - alive
sessions: ['session1', 'session2'],
startedAt,
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
const result = getProxyStatus();
assert.strictEqual(result.running, true);
assert.strictEqual(result.port, testPort);
assert.strictEqual(result.pid, process.pid);
assert.strictEqual(result.sessionCount, 2);
assert.strictEqual(result.startedAt, startedAt);
});
it('should cleanup and return not running when proxy is dead', function () {
const lock = {
port: testPort,
pid: 999999999, // Dead PID
sessions: ['session1'],
startedAt: new Date().toISOString(),
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
const result = getProxyStatus();
assert.strictEqual(result.running, false);
assert.strictEqual(fs.existsSync(sessionLockPath), false);
});
it('should return correct session count after registrations', function () {
registerSession(testPort, process.pid);
let status = getProxyStatus();
assert.strictEqual(status.sessionCount, 1);
registerSession(testPort, process.pid);
status = getProxyStatus();
assert.strictEqual(status.sessionCount, 2);
registerSession(testPort, process.pid);
status = getProxyStatus();
assert.strictEqual(status.sessionCount, 3);
});
});
describe('Multi-session scenario', function () {
it('should handle complete multi-terminal workflow', function () {
// Terminal 1 starts - first session
@@ -357,3 +357,156 @@ describe('API Command - Model Fields Fix', () => {
});
});
});
describe('CLIProxy Command - Proxy Lifecycle', () => {
// Test isolation environment
let testHome;
let sessionLockPath;
beforeEach(() => {
testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-lifecycle-'));
process.env.CCS_HOME = testHome;
const cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
sessionLockPath = path.join(cliproxyDir, 'sessions.json');
});
afterEach(() => {
if (testHome && fs.existsSync(testHome)) {
fs.rmSync(testHome, { recursive: true, force: true });
}
delete process.env.CCS_HOME;
});
describe('Status Command Logic', () => {
it('returns not running when no session lock exists', () => {
// Simulate getProxyStatus behavior
const lockExists = fs.existsSync(sessionLockPath);
assert.strictEqual(lockExists, false);
// Status should indicate not running
const status = { running: false };
assert.strictEqual(status.running, false);
});
it('returns running with details when session lock exists', () => {
// Create mock session lock
const lock = {
port: 8317,
pid: process.pid,
sessions: ['session1', 'session2'],
startedAt: new Date().toISOString(),
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock, null, 2));
// Read and verify
const data = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
assert.strictEqual(data.port, 8317);
assert.strictEqual(data.pid, process.pid);
assert.strictEqual(data.sessions.length, 2);
assert(data.startedAt);
});
it('formats uptime correctly', () => {
// Test uptime formatting logic
const formatUptime = (ms) => {
const hours = Math.floor(ms / (1000 * 60 * 60));
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
assert.strictEqual(formatUptime(30 * 60 * 1000), '30m'); // 30 minutes
assert.strictEqual(formatUptime(90 * 60 * 1000), '1h 30m'); // 1.5 hours
assert.strictEqual(formatUptime(2 * 60 * 60 * 1000 + 15 * 60 * 1000), '2h 15m'); // 2h 15m
});
});
describe('Stop Command Logic', () => {
it('returns error when no session lock exists', () => {
// Verify lock doesn't exist
assert.strictEqual(fs.existsSync(sessionLockPath), false);
// Stop should fail with appropriate error
const result = { stopped: false, error: 'No active CLIProxy session found' };
assert.strictEqual(result.stopped, false);
assert.strictEqual(result.error, 'No active CLIProxy session found');
});
it('cleans up stale lock for dead process', () => {
// Create lock with non-existent PID
const lock = {
port: 8317,
pid: 999999999, // Very unlikely to exist
sessions: ['session1'],
startedAt: new Date().toISOString(),
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock, null, 2));
// Verify lock was created
assert.strictEqual(fs.existsSync(sessionLockPath), true);
// Simulate isProcessRunning check
const isRunning = (() => {
try {
process.kill(999999999, 0);
return true;
} catch {
return false;
}
})();
assert.strictEqual(isRunning, false);
// Cleanup should remove lock
if (!isRunning) {
fs.unlinkSync(sessionLockPath);
}
assert.strictEqual(fs.existsSync(sessionLockPath), false);
});
it('returns session count when stopping active proxy', () => {
// Create lock with current process
const lock = {
port: 8317,
pid: process.pid,
sessions: ['session1', 'session2', 'session3'],
startedAt: new Date().toISOString(),
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock, null, 2));
// Read and verify session count
const data = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
assert.strictEqual(data.sessions.length, 3);
// Result structure should include count
const result = {
stopped: true,
pid: data.pid,
sessionCount: data.sessions.length,
};
assert.strictEqual(result.stopped, true);
assert.strictEqual(result.sessionCount, 3);
});
});
describe('Command Routing', () => {
it('routes "stop" subcommand correctly', () => {
const args = ['cliproxy', 'stop'];
const subcommand = args[1];
assert.strictEqual(subcommand, 'stop');
});
it('routes "status" subcommand correctly', () => {
const args = ['cliproxy', 'status'];
const subcommand = args[1];
assert.strictEqual(subcommand, 'status');
});
it('handles unknown subcommand', () => {
const validSubcommands = ['stop', 'status', 'create', 'list', 'remove'];
const unknownCommand = 'invalid';
assert.strictEqual(validSubcommands.includes(unknownCommand), false);
});
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Proxy Status Widget
*
* Displays CLIProxy process status with start button for recovery.
* Shows: running state, port, session count, uptime.
*/
import { Activity, Power, RefreshCw, Clock, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useProxyStatus, useStartProxy } from '@/hooks/use-cliproxy';
import { cn } from '@/lib/utils';
function formatUptime(startedAt?: string): string {
if (!startedAt) return '';
const start = new Date(startedAt).getTime();
const now = Date.now();
const diff = now - start;
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
export function ProxyStatusWidget() {
const { data: status, isLoading } = useProxyStatus();
const startProxy = useStartProxy();
const isRunning = status?.running ?? false;
return (
<div
className={cn(
'rounded-lg border p-3 transition-colors',
isRunning ? 'border-green-500/30 bg-green-500/5' : 'border-muted bg-muted/30'
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
isRunning ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground/30'
)}
/>
<span className="text-sm font-medium">CLIProxy Service</span>
</div>
<div className="flex items-center gap-1">
{isLoading ? (
<RefreshCw className="w-3 h-3 animate-spin text-muted-foreground" />
) : isRunning ? (
<Activity className="w-3 h-3 text-green-600" />
) : (
<Power className="w-3 h-3 text-muted-foreground" />
)}
</div>
</div>
{isRunning && status ? (
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">Port {status.port}</span>
{status.sessionCount !== undefined && status.sessionCount > 0 && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
</span>
)}
{status.startedAt && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatUptime(status.startedAt)}
</span>
)}
</div>
) : (
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">Not running</span>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => startProxy.mutate()}
disabled={startProxy.isPending}
>
{startProxy.isPending ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Power className="w-3 h-3" />
)}
Start
</Button>
</div>
)}
</div>
);
}
+31
View File
@@ -209,3 +209,34 @@ export function useDeletePreset() {
},
});
}
// ==================== Proxy Process Status ====================
export function useProxyStatus() {
return useQuery({
queryKey: ['proxy-status'],
queryFn: () => api.cliproxy.proxyStatus(),
refetchInterval: 30000, // Refresh every 30s as backup (websocket is primary)
});
}
export function useStartProxy() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.cliproxy.proxyStart(),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['proxy-status'] });
if (data.alreadyRunning) {
toast.info('CLIProxy was already running');
} else if (data.started) {
toast.success('CLIProxy started successfully');
} else {
toast.error(data.error || 'Failed to start CLIProxy');
}
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
+4
View File
@@ -48,6 +48,10 @@ export function useWebSocket() {
toast.info('Accounts updated');
break;
case 'proxy-status-changed':
queryClient.invalidateQueries({ queryKey: ['proxy-status'] });
break;
case 'pong':
// Heartbeat response
break;
+22
View File
@@ -154,6 +154,24 @@ export interface CreatePreset {
haiku?: string;
}
/** CLIProxy process status from session tracker */
export interface ProxyProcessStatus {
running: boolean;
port?: number;
pid?: number;
sessionCount?: number;
startedAt?: string;
}
/** Result from starting proxy service */
export interface ProxyStartResult {
started: boolean;
alreadyRunning: boolean;
port: number;
configRegenerated?: boolean;
error?: string;
}
// API
export const api = {
profiles: {
@@ -185,6 +203,10 @@ export const api = {
}),
delete: (name: string) => request(`/cliproxy/${name}`, { method: 'DELETE' }),
// Proxy process status and control
proxyStatus: () => request<ProxyProcessStatus>('/cliproxy/proxy-status'),
proxyStart: () => request<ProxyStartResult>('/cliproxy/proxy-start', { method: 'POST' }),
// Stats and models for Overview tab
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
models: () => request<CliproxyModelsResponse>('/cliproxy/models'),
+6
View File
@@ -15,6 +15,7 @@ import { QuickSetupWizard } from '@/components/quick-setup-wizard';
import { AddAccountDialog } from '@/components/add-account-dialog';
import { ProviderEditor } from '@/components/cliproxy/provider-editor';
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
import { ProxyStatusWidget } from '@/components/proxy-status-widget';
import {
useCliproxy,
useCliproxyAuth,
@@ -307,6 +308,11 @@ export function CliproxyPage() {
</div>
</ScrollArea>
{/* Proxy Status Widget */}
<div className="p-3 border-t">
<ProxyStatusWidget />
</div>
{/* Footer Stats */}
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">