diff --git a/.envrc.example b/.envrc.example new file mode 100644 index 00000000..52287d58 --- /dev/null +++ b/.envrc.example @@ -0,0 +1,17 @@ +# Happy CLI Development Environment +# +# This file is for direnv users who want automatic environment switching +# when entering this directory. +# +# Setup: +# 1. Install direnv: https://direnv.net/ +# 2. Copy this file: cp .envrc.example .envrc +# 3. Run: direnv allow +# +# The .envrc file is gitignored, so each developer can customize it. + +export HAPPY_HOME_DIR="$HOME/.happy-dev" +export HAPPY_VARIANT="dev" +export HAPPY_SERVER_URL="${HAPPY_SERVER_URL:-https://api.cluster-fluster.com}" + +echo "šŸ”§ DEV environment activated (data: $HAPPY_HOME_DIR)" diff --git a/.gitignore b/.gitignore index f33d308c..c41e1941 100644 --- a/.gitignore +++ b/.gitignore @@ -17,12 +17,17 @@ pnpm-lock.yaml # Environment variables .env .env*.local +.envrc # Claude code session level settings .claude/settings.local.json -# Local installation +# Local installation and data directories .happy/ +.happy-dev/ **/*.log -.release-notes-temp.md \ No newline at end of file +.release-notes-temp.md + +# Git worktrees for isolated branch work +.worktrees/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..ccb1eb28 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,333 @@ +# Contributing to Happy CLI + +## Prerequisites + +- Node.js >= 20.0.0 +- Yarn (`npm install -g yarn`) +- Git +- Claude CLI installed and logged in (`claude` command available in PATH) + +## Getting Started + +```bash +git clone https://github.com/slopus/happy-cli.git +cd happy-cli +yarn install +yarn build +``` + +## Development Commands + +### Global `happy-dev` Command + +Create a global `happy-dev` command that runs your local development build: + +```bash +yarn link:dev # Create happy-dev symlink +yarn unlink:dev # Remove happy-dev symlink +``` + +This creates a `happy-dev` command in your PATH pointing to your local build, while leaving any npm-installed `happy` command untouched. + +| Command | Runs | +|---------|------| +| `happy` | Stable npm version (from `npm install -g happy-coder`) | +| `happy-dev` | Local development version (from this repo) | + +**Note:** Run `yarn build` before `yarn link:dev` to ensure the binary exists. + +### Build Commands + +```bash +yarn build # Build the project +yarn typecheck # TypeScript type checking +yarn test # Run tests +yarn dev # Run without building (uses tsx) +``` + +## Stable vs Dev Data Isolation + +The CLI supports running stable and development versions side-by-side with completely isolated data. + +### Initial Setup (Once) + +```bash +npm run setup:dev +``` + +This creates: +- `~/.happy/` - Stable version data (production-ready) +- `~/.happy-dev/` - Development version data (for testing changes) + +### Daily Usage + +**Stable (production-ready):** +```bash +npm run stable:daemon:start +``` + +**Development (testing changes):** +```bash +npm run dev:daemon:start +``` + +## Visual Indicators + +You'll always see which version you're using: +- `āœ… STABLE MODE - Data: ~/.happy` +- `šŸ”§ DEV MODE - Data: ~/.happy-dev` + +## Common Tasks + +### Authentication + +```bash +# Authenticate stable version +npm run stable auth login + +# Authenticate dev version (can use same or different account) +npm run dev auth login + +# Logout +npm run stable auth logout +npm run dev auth logout +``` + +### Daemon Management + +```bash +# Check status of both +npm run stable:daemon:status +npm run dev:daemon:status + +# Stop both +npm run stable:daemon:stop +npm run dev:daemon:stop + +# Start both simultaneously +npm run stable:daemon:start && npm run dev:daemon:start +``` + +### Running Any Command + +```bash +# Stable version +npm run stable [args...] +npm run stable notify "Test message" +npm run stable doctor + +# Dev version +npm run dev:variant [args...] +npm run dev:variant notify "Test message" +npm run dev:variant doctor +``` + +## Data Isolation + +Both versions maintain complete separation: + +| Aspect | Stable | Development | +|--------|--------|-------------| +| Data Directory | `~/.happy/` | `~/.happy-dev/` | +| Settings | `~/.happy/settings.json` | `~/.happy-dev/settings.json` | +| Auth Keys | `~/.happy/access.key` | `~/.happy-dev/access.key` | +| Daemon State | `~/.happy/daemon.state.json` | `~/.happy-dev/daemon.state.json` | +| Logs | `~/.happy/logs/` | `~/.happy-dev/logs/` | + +**No conflicts!** Both can run simultaneously with separate: +- Authentication sessions +- Server connections +- Daemon processes +- Session histories +- Configuration settings + +## Advanced: direnv Auto-Switching + +For automatic environment switching when entering directories: + +1. Install [direnv](https://direnv.net/): + ```bash + # macOS + brew install direnv + + # Add to your shell (bash/zsh) + eval "$(direnv hook bash)" # or zsh + ``` + +2. Setup direnv for this project: + ```bash + cp .envrc.example .envrc + direnv allow + ``` + +3. Now `cd` into the directory automatically sets `HAPPY_VARIANT=dev`! + +## Troubleshooting + +### Commands not working? +```bash +npm install +``` + +### Permission denied on scripts? +```bash +chmod +x scripts/*.cjs +``` + +### Data directories not created? +```bash +npm run setup:dev +``` + +### Both daemons won't start? +Check port conflicts - each daemon needs its own port. The dev daemon will automatically use a different port from stable. + +### How do I check which version is running? +Look for the visual indicator: +- `āœ… STABLE MODE` = stable version +- `šŸ”§ DEV MODE` = development version + +Or check the daemon status: +```bash +npm run stable:daemon:status # Shows ~/.happy/ data location +npm run dev:daemon:status # Shows ~/.happy-dev/ data location +``` + +### `yarn link:dev` fails with permission denied? +```bash +sudo yarn link:dev +``` + +### `happy-dev` command not found after linking? +- Ensure your global npm bin is in PATH: `npm bin -g` +- Try opening a new terminal window +- Check the symlink was created: `ls -la $(npm bin -g)/happy-dev` + +## Tips + +1. **Use stable for production work** - Your tested, reliable version +2. **Use dev for testing changes** - Test new features without breaking your workflow +3. **Run both simultaneously** - Compare behavior side-by-side +4. **Different accounts** - Use different Happy accounts for dev/stable if needed +5. **Check logs** - Logs are separated: `~/.happy/logs/` vs `~/.happy-dev/logs/` + +## Example Workflow + +```bash +# Initial setup (once) +yarn install +yarn build +yarn link:dev +npm run setup:dev + +# Authenticate both +npm run stable auth login +npm run dev:variant auth login + +# Start both daemons +npm run stable:daemon:start +npm run dev:daemon:start + +# Do your development work... +# Edit code, build, test with dev version + +# When ready, update stable version +npm run stable:daemon:stop +git pull # or your deployment process +npm run stable:daemon:start + +# Dev continues running unaffected! +``` + +## How It Works + +The system uses the built-in `HAPPY_HOME_DIR` environment variable to separate data: + +- **Stable scripts** set: `HAPPY_HOME_DIR=~/.happy` +- **Dev scripts** set: `HAPPY_HOME_DIR=~/.happy-dev` + +Everything else (auth, sessions, logs, daemon) automatically follows the `HAPPY_HOME_DIR` setting. + +Cross-platform via Node.js - works identically on Windows, macOS, and Linux! + +## Testing Profile Sync Between GUI and CLI + +Profile synchronization ensures AI backend configurations created in the Happy mobile/web GUI work seamlessly with the CLI daemon. + +### Profile Schema Validation + +The profile schema is defined in both repositories: +- **GUI:** `sources/sync/settings.ts` (AIBackendProfileSchema) +- **CLI:** `src/persistence.ts` (AIBackendProfileSchema) + +**Critical:** These schemas MUST stay in sync to prevent sync failures. + +### Testing Profile Sync + +1. **Create profile in GUI:** + ``` + - Open Happy mobile/web app + - Settings → AI Backend Profiles + - Create new profile with custom environment variables + - Note the profile ID + ``` + +2. **Verify CLI receives profile:** + ```bash + # Start daemon with dev variant + npm run dev:daemon:start + + # Check daemon logs + tail -f ~/.happy-dev/logs/*.log | grep -i profile + ``` + +3. **Test profile-based session spawning:** + ```bash + # From GUI: Start new session with custom profile + # Check CLI daemon logs for: + # - "Loaded X environment variables from profile" + # - "Using GUI-provided profile environment variables" + ``` + +4. **Verify environment variable expansion:** + ```bash + # If profile uses ${VAR} references: + # - Set reference var in daemon environment: export Z_AI_AUTH_TOKEN="sk-..." + # - Start session from GUI + # - Verify daemon logs show expansion: "${Z_AI_AUTH_TOKEN}" → "sk-..." + ``` + +### Testing Schema Compatibility + +When modifying profile schemas: + +1. **Update both repositories** - Never update one without the other +2. **Test migration** - Existing profiles should migrate gracefully +3. **Version bump** - Update `CURRENT_PROFILE_VERSION` if schema changes +4. **Test validation** - Invalid profiles should be caught with clear errors + +### Common Issues + +**"Invalid profile" warnings in logs:** +- Check profile has valid UUID (not timestamp) +- Verify environment variable names match regex: `^[A-Z_][A-Z0-9_]*$` +- Ensure compatibility.claude or compatibility.codex is true + +**Environment variables not expanding:** +- Reference variable must be set in daemon's process.env +- Check daemon logs for expansion warnings +- Verify no typos in ${VAR} references + +## Publishing to npm + +Maintainers can publish new versions: + +```bash +yarn release # Interactive version bump, changelog, publish +``` + +This runs tests, builds, and publishes to npm. The published package includes: +- `happy` - Main CLI command +- `happy-mcp` - MCP bridge command + +**Note:** `happy-dev` is intentionally excluded from the npm package - it's for local development only. diff --git a/README.md b/README.md index d8ab661a..215c895e 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ This will: - `HAPPY_DISABLE_CAFFEINATE` - Disable macOS sleep prevention (set to `true`, `1`, or `yes`) - `HAPPY_EXPERIMENTAL` - Enable experimental features (set to `true`, `1`, or `yes`) +## Contributing + +Interested in contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. + ## Requirements - Node.js >= 20.0.0 diff --git a/bin/happy-dev.mjs b/bin/happy-dev.mjs new file mode 100755 index 00000000..4a576e51 --- /dev/null +++ b/bin/happy-dev.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node + +import { execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; + +// Check if we're already running with the flags +const hasNoWarnings = process.execArgv.includes('--no-warnings'); +const hasNoDeprecation = process.execArgv.includes('--no-deprecation'); + +if (!hasNoWarnings || !hasNoDeprecation) { + // Re-execute with the flags + const __filename = fileURLToPath(import.meta.url); + const scriptPath = join(dirname(__filename), '../dist/index.mjs'); + + // Set development environment variables + process.env.HAPPY_HOME_DIR = join(homedir(), '.happy-dev'); + process.env.HAPPY_VARIANT = 'dev'; + + try { + execFileSync( + process.execPath, + ['--no-warnings', '--no-deprecation', scriptPath, ...process.argv.slice(2)], + { + stdio: 'inherit', + env: process.env + } + ); + } catch (error) { + // Exit with the same code as the subprocess + process.exit(error.status || 1); + } +} else { + // Already have the flags, import normally + // Set development environment variables + process.env.HAPPY_HOME_DIR = join(homedir(), '.happy-dev'); + process.env.HAPPY_VARIANT = 'dev'; + + await import('../dist/index.mjs'); +} diff --git a/package.json b/package.json index 59e02675..07f1b0d8 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,26 @@ "dev:integration-test-env": "yarn build && tsx --env-file .env.integration-test src/index.ts", "prepublishOnly": "yarn build && yarn test", "release": "release-it", - "postinstall": "node scripts/unpack-tools.cjs" + "postinstall": "node scripts/unpack-tools.cjs", + "// ==== Dev/Stable Variant Management ====": "", + "stable": "node scripts/env-wrapper.cjs stable", + "dev:variant": "node scripts/env-wrapper.cjs dev", + "// ==== Stable Version Quick Commands ====": "", + "stable:daemon:start": "node scripts/env-wrapper.cjs stable daemon start", + "stable:daemon:stop": "node scripts/env-wrapper.cjs stable daemon stop", + "stable:daemon:status": "node scripts/env-wrapper.cjs stable daemon status", + "stable:auth": "node scripts/env-wrapper.cjs stable auth", + "// ==== Development Version Quick Commands ====": "", + "dev:daemon:start": "node scripts/env-wrapper.cjs dev daemon start", + "dev:daemon:stop": "node scripts/env-wrapper.cjs dev daemon stop", + "dev:daemon:status": "node scripts/env-wrapper.cjs dev daemon status", + "dev:auth": "node scripts/env-wrapper.cjs dev auth", + "// ==== Setup ====": "", + "setup:dev": "node scripts/setup-dev.cjs", + "doctor": "node scripts/env-wrapper.cjs stable doctor", + "// ==== Development Linking ====": "", + "link:dev": "node scripts/link-dev.cjs", + "unlink:dev": "node scripts/link-dev.cjs unlink" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.22.0", diff --git a/scripts/__tests__/ripgrep_launcher.test.ts b/scripts/__tests__/ripgrep_launcher.test.ts new file mode 100644 index 00000000..258dcb72 --- /dev/null +++ b/scripts/__tests__/ripgrep_launcher.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('Ripgrep Launcher Runtime Compatibility', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('has correct file structure', () => { + // Test that the launcher file has the correct structure + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check for required elements + expect(content).toContain('#!/usr/bin/env node'); + expect(content).toContain('ripgrepMain'); + expect(content).toContain('loadRipgrepNative'); + }).not.toThrow(); + }); + + it('handles --version argument gracefully', () => { + // Test that --version handling logic exists + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check that --version handling is present + expect(content).toContain('--version'); + expect(content).toContain('ripgrepMain'); + }).not.toThrow(); + }); + + it('detects runtime correctly', () => { + // Test runtime detection function exists + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check that runtime detection logic is present + expect(content).toContain('detectRuntime'); + expect(content).toContain('typeof Bun'); + expect(content).toContain('typeof Deno'); + expect(content).toContain('process?.versions'); + }).not.toThrow(); + }); + + it('contains fallback chain logic', () => { + // Test that fallback logic is present + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check that fallback chain is present + expect(content).toContain('loadRipgrepNative'); + expect(content).toContain('systemRipgrep'); + expect(content).toContain('createRipgrepWrapper'); + expect(content).toContain('createMockRipgrep'); + }).not.toThrow(); + }); + + it('contains cross-platform logic', () => { + // Test that cross-platform logic is present + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check for platform-specific logic + expect(content).toContain('process.platform'); + expect(content).toContain('win32'); + expect(content).toContain('darwin'); + expect(content).toContain('linux'); + expect(content).toContain('execFileSync'); + }).not.toThrow(); + }); + + it('provides helpful error messages', () => { + // Test that helpful error messages are present + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check for helpful messages + expect(content).toContain('brew install ripgrep'); + expect(content).toContain('winget install BurntSushi.ripgrep'); + expect(content).toContain('Search functionality unavailable'); + }).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/scripts/claude_version_utils.cjs b/scripts/claude_version_utils.cjs index 921729e5..73776a85 100644 --- a/scripts/claude_version_utils.cjs +++ b/scripts/claude_version_utils.cjs @@ -49,72 +49,191 @@ function findNpmGlobalCliPath() { } /** - * Find path to Homebrew installed Claude Code CLI - * @returns {string|null} Path to cli.js or binary, or null if not found + * Find Claude CLI using system PATH (which/where command) + * Respects user's configuration and works across all platforms + * @returns {{path: string, source: string}|null} Path and source, or null if not found */ -function findHomebrewCliPath() { - if (process.platform !== 'darwin' && process.platform !== 'linux') { - return null; +function findClaudeInPath() { + try { + // Cross-platform: 'where' on Windows, 'which' on Unix + const command = process.platform === 'win32' ? 'where claude' : 'which claude'; + const claudePath = execSync(command, { encoding: 'utf8' }) + .trim() + .split('\n')[0]; // Take first match + + const resolvedPath = resolvePathSafe(claudePath); + + if (resolvedPath && fs.existsSync(resolvedPath)) { + // Detect source from BOTH original PATH entry and resolved path + // Original path tells us HOW user accessed it (context) + // Resolved path tells us WHERE it actually lives (content) + const originalSource = detectSourceFromPath(claudePath); + const resolvedSource = detectSourceFromPath(resolvedPath); + + // Prioritize original PATH entry for context (e.g., bun vs npm access) + // Fall back to resolved path for accurate location detection + const source = originalSource !== 'PATH' ? originalSource : resolvedSource; + + return { + path: resolvedPath, + source: source + }; + } + } catch (e) { + // Command failed (claude not in PATH) } - - // Try to get Homebrew prefix via command first - let brewPrefix = null; + return null; +} + +/** + * Detect installation source from resolved path + * Uses concrete path patterns, no assumptions + * @param {string} resolvedPath - The resolved path to cli.js + * @returns {string} Installation method/source + */ +function detectSourceFromPath(resolvedPath) { + const normalized = resolvedPath.toLowerCase(); + const path = require('path'); + + // Use path.normalize() for proper cross-platform path handling + const normalizedPath = path.normalize(resolvedPath).toLowerCase(); + + // Bun: ~/.bun/bin/claude -> ../node_modules/@anthropic-ai/claude-code/cli.js + // Works on Windows too: C:\Users\[user]\.bun\bin\claude + if (normalizedPath.includes('.bun') && normalizedPath.includes('bin') || + (normalizedPath.includes('node_modules') && normalizedPath.includes('.bun'))) { + return 'Bun'; + } + + // Homebrew cask: hashed directories like .claude-code-2DTsDk1V (NOT npm installations) + // Must check before general Homebrew paths to distinguish from npm-through-Homebrew + if (normalizedPath.includes('@anthropic-ai') && normalizedPath.includes('.claude-code-')) { + return 'Homebrew'; + } + + // npm: clean claude-code directory (even through Homebrew's npm) + // Windows: %APPDATA%\npm\node_modules\@anthropic-ai\claude-code + if (normalizedPath.includes('node_modules') && normalizedPath.includes('@anthropic-ai') && normalizedPath.includes('claude-code') && + !normalizedPath.includes('.claude-code-')) { + return 'npm'; + } + + // Windows-specific detection (detect by path patterns, not current platform) + if (normalizedPath.includes('appdata') || normalizedPath.includes('program files') || normalizedPath.endsWith('.exe')) { + // Windows npm + if (normalizedPath.includes('appdata') && normalizedPath.includes('npm') && normalizedPath.includes('node_modules')) { + return 'npm'; + } + + // Windows native installer (any location ending with claude.exe) + if (normalizedPath.endsWith('claude.exe')) { + return 'native installer'; + } + + // Windows native installer in AppData + if (normalizedPath.includes('appdata') && normalizedPath.includes('claude')) { + return 'native installer'; + } + + // Windows native installer in Program Files + if (normalizedPath.includes('program files') && normalizedPath.includes('claude')) { + return 'native installer'; + } + } + + // Homebrew general paths (for non-npm installations like Cellar binaries) + // Apple Silicon: /opt/homebrew/bin/claude + // Intel Mac: /usr/local/bin/claude (ONLY on macOS, not Linux) + // Linux Homebrew: /home/linuxbrew/.linuxbrew/bin/claude or ~/.linuxbrew/bin/claude + if (normalizedPath.includes('opt/homebrew') || + normalizedPath.includes('usr/local/homebrew') || + normalizedPath.includes('home/linuxbrew') || + normalizedPath.includes('.linuxbrew') || + normalizedPath.includes('.homebrew') || + normalizedPath.includes('cellar') || + normalizedPath.includes('caskroom') || + (normalizedPath.includes('usr/local/bin/claude') && process.platform === 'darwin')) { // Intel Mac Homebrew default only on macOS + return 'Homebrew'; + } + + // Native installer: standard Unix locations and ~/.local/bin + // /usr/local/bin/claude on Linux should be native installer + if (normalizedPath.includes('.local') && normalizedPath.includes('bin') || + normalizedPath.includes('.local') && normalizedPath.includes('share') && normalizedPath.includes('claude') || + (normalizedPath.includes('usr/local/bin/claude') && process.platform === 'linux')) { // Linux native installer + return 'native installer'; + } + + // Default: we found it in PATH but can't determine source + return 'PATH'; +} + +/** + * Find path to Bun globally installed Claude Code CLI + * FIX: Check bun's bin directory, not non-existent modules directory + * @returns {string|null} Path to cli.js or null if not found + */ +function findBunGlobalCliPath() { + // First check if bun command exists (cross-platform) try { - brewPrefix = execSync('brew --prefix 2>/dev/null', { encoding: 'utf8' }).trim(); + const bunCheckCommand = process.platform === 'win32' ? 'where bun' : 'which bun'; + execSync(bunCheckCommand, { encoding: 'utf8' }); } catch (e) { - // brew command not in PATH, try standard locations + return null; // bun not installed } - - // Standard Homebrew locations to check - const possiblePrefixes = []; - if (brewPrefix) { - possiblePrefixes.push(brewPrefix); + + // Check bun's binary directory (works on both Unix and Windows) + const bunBin = path.join(os.homedir(), '.bun', 'bin', 'claude'); + const resolved = resolvePathSafe(bunBin); + + if (resolved && resolved.endsWith('cli.js') && fs.existsSync(resolved)) { + return resolved; } - - // Add standard locations based on platform - if (process.platform === 'darwin') { - // macOS: Intel (/usr/local) or Apple Silicon (/opt/homebrew) - possiblePrefixes.push('/opt/homebrew', '/usr/local'); - } else if (process.platform === 'linux') { - // Linux: system-wide or user installation - const homeDir = os.homedir(); - possiblePrefixes.push('/home/linuxbrew/.linuxbrew', path.join(homeDir, '.linuxbrew')); + + return null; +} + +/** + * Find path to Homebrew installed Claude Code CLI + * FIX: Handle hashed directory names like .claude-code-[hash] + * @returns {string|null} Path to cli.js or binary, or null if not found + */ +function findHomebrewCliPath() { + if (process.platform !== 'darwin' && process.platform !== 'linux') { + return null; } - - // Check each possible prefix + + const possiblePrefixes = [ + '/opt/homebrew', + '/usr/local', + path.join(os.homedir(), '.linuxbrew'), + path.join(os.homedir(), '.homebrew') + ].filter(fs.existsSync); + for (const prefix of possiblePrefixes) { - if (!fs.existsSync(prefix)) { - continue; - } - - // Homebrew installs claude-code as a Cask (binary) in Caskroom - const caskroomPath = path.join(prefix, 'Caskroom', 'claude-code'); - if (fs.existsSync(caskroomPath)) { - const found = findLatestVersionBinary(caskroomPath, 'claude'); - if (found) return found; + // Check for binary symlink first (most reliable) + const binPath = path.join(prefix, 'bin', 'claude'); + const resolved = resolvePathSafe(binPath); + if (resolved && fs.existsSync(resolved)) { + return resolved; } - - // Also check Cellar (for formula installations, though claude-code is usually a Cask) - const cellarPath = path.join(prefix, 'Cellar', 'claude-code'); - if (fs.existsSync(cellarPath)) { - // Cellar has different structure - check for cli.js in libexec - const entries = fs.readdirSync(cellarPath); - if (entries.length > 0) { - const sorted = entries.sort((a, b) => compareVersions(b, a)); - const latestVersion = sorted[0]; - const cliPath = path.join(cellarPath, latestVersion, 'libexec', 'lib', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'); - if (fs.existsSync(cliPath)) { - return cliPath; + + // Fallback: check for hashed directories in node_modules + const nodeModulesPath = path.join(prefix, 'lib', 'node_modules', '@anthropic-ai'); + if (fs.existsSync(nodeModulesPath)) { + // Look for both claude-code and .claude-code-[hash] + const entries = fs.readdirSync(nodeModulesPath); + for (const entry of entries) { + if (entry === 'claude-code' || entry.startsWith('.claude-code-')) { + const cliPath = path.join(nodeModulesPath, entry, 'cli.js'); + if (fs.existsSync(cliPath)) { + return cliPath; + } } } } - - // Check bin directory for symlink (most reliable) - const binPath = path.join(prefix, 'bin', 'claude'); - const resolvedBinPath = resolvePathSafe(binPath); - if (resolvedBinPath) return resolvedBinPath; } - + return null; } @@ -251,22 +370,24 @@ function findLatestVersionBinary(versionsDir, binaryName = null) { /** * Find path to globally installed Claude Code CLI - * Checks multiple installation methods in order of preference: - * 1. npm global (highest priority) - * 2. Homebrew - * 3. Native installer + * Priority: PATH (user preference) > npm > Bun > Homebrew > Native * @returns {{path: string, source: string}|null} Path and source, or null if not found */ function findGlobalClaudeCliPath() { - // Check npm global first (highest priority) + // 1. Check PATH first (respects user's choice) + const pathResult = findClaudeInPath(); + if (pathResult) return pathResult; + + // 2. Fall back to package manager detection const npmPath = findNpmGlobalCliPath(); if (npmPath) return { path: npmPath, source: 'npm' }; - // Check Homebrew installation + const bunPath = findBunGlobalCliPath(); + if (bunPath) return { path: bunPath, source: 'Bun' }; + const homebrewPath = findHomebrewCliPath(); if (homebrewPath) return { path: homebrewPath, source: 'Homebrew' }; - // Check native installer const nativePath = findNativeInstallerCliPath(); if (nativePath) return { path: nativePath, source: 'native installer' }; @@ -367,7 +488,10 @@ function runClaudeCli(cliPath) { module.exports = { findGlobalClaudeCliPath, + findClaudeInPath, + detectSourceFromPath, findNpmGlobalCliPath, + findBunGlobalCliPath, findHomebrewCliPath, findNativeInstallerCliPath, getVersion, diff --git a/scripts/claude_version_utils.test.ts b/scripts/claude_version_utils.test.ts new file mode 100644 index 00000000..ecd6a7ff --- /dev/null +++ b/scripts/claude_version_utils.test.ts @@ -0,0 +1,338 @@ +import { describe, it, expect } from 'vitest'; +import { + findGlobalClaudeCliPath, + findClaudeInPath, + detectSourceFromPath, + findNpmGlobalCliPath, + findBunGlobalCliPath, + findHomebrewCliPath, + findNativeInstallerCliPath, + getVersion, + compareVersions +} from '../scripts/claude_version_utils.cjs'; + +describe('Claude Version Utils - Cross-Platform Detection', () => { + + describe('detectSourceFromPath', () => { + + describe('npm installations', () => { + it('should detect npm global installation on macOS/Linux', () => { + const result = detectSourceFromPath('/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('npm'); + }); + + it('should detect npm global installation on Windows with forward slashes', () => { + const result = detectSourceFromPath('C:/Users/test/AppData/Roaming/npm/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('npm'); + }); + + it('should detect npm global installation on Windows with backslashes', () => { + const result = detectSourceFromPath('C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\cli.js'); + expect(result).toBe('npm'); + }); + + it('should detect npm with different scoped packages', () => { + const result = detectSourceFromPath('C:/Users/test/AppData/Roaming/npm/node_modules/@babel/core/cli.js'); + expect(result).toBe('npm'); + }); + + it('should detect npm through Homebrew', () => { + const result = detectSourceFromPath('/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('npm'); + }); + + it('should NOT detect Homebrew cask as npm', () => { + const result = detectSourceFromPath('/opt/homebrew/lib/node_modules/@anthropic-ai/.claude-code-2DTsDk1V/cli.js'); + expect(result).toBe('Homebrew'); + }); + }); + + describe('Bun installations', () => { + it('should detect Bun global installation on Unix', () => { + const result = detectSourceFromPath('/Users/test/.bun/bin/claude'); + expect(result).toBe('Bun'); + }); + + it('should detect Bun global installation on Windows', () => { + const result = detectSourceFromPath('C:/Users/test/.bun/bin/claude'); + expect(result).toBe('Bun'); + }); + + it('should detect Bun with @ symbol in username', () => { + const result = detectSourceFromPath('C:/Users/@specialuser/.bun/bin/claude'); + expect(result).toBe('Bun'); + }); + + it('should detect Bun in node_modules context', () => { + const result = detectSourceFromPath('/Users/test/.bun/install/global/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('Bun'); + }); + }); + + describe('Homebrew installations', () => { + it('should detect Homebrew on Apple Silicon macOS', () => { + const result = detectSourceFromPath('/opt/homebrew/bin/claude'); + expect(result).toBe('Homebrew'); + }); + + it('should detect Homebrew on Intel macOS', () => { + // Mock macOS platform + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + + const result = detectSourceFromPath('/usr/local/bin/claude'); + expect(result).toBe('Homebrew'); + + // Restore original platform + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + + it('should detect native installer on Linux for /usr/local/bin/claude', () => { + // Mock Linux platform + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + + const result = detectSourceFromPath('/usr/local/bin/claude'); + expect(result).toBe('native installer'); + + // Restore original platform + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + + it('should detect Homebrew on Linux', () => { + const result = detectSourceFromPath('/home/linuxbrew/.linuxbrew/bin/claude'); + expect(result).toBe('Homebrew'); + }); + + it('should detect Homebrew user installation', () => { + const result = detectSourceFromPath('/Users/test/.linuxbrew/bin/claude'); + expect(result).toBe('Homebrew'); + }); + + it('should detect Homebrew cask with hashed directory', () => { + const result = detectSourceFromPath('/opt/homebrew/lib/node_modules/@anthropic-ai/.claude-code-2DTsDk1V/cli.js'); + expect(result).toBe('Homebrew'); + }); + + it('should detect Homebrew Cellar installation', () => { + const result = detectSourceFromPath('/opt/homebrew/Cellar/claude-code/1.0.0/bin/claude'); + expect(result).toBe('Homebrew'); + }); + }); + + describe('Native installer installations', () => { + it('should detect native installer on Unix ~/.local', () => { + const result = detectSourceFromPath('/Users/test/.local/bin/claude'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer with versioned structure', () => { + const result = detectSourceFromPath('/Users/test/.local/share/claude/versions/2.0.69/claude'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer on Windows Program Files', () => { + const result = detectSourceFromPath('C:/Program Files/Claude/claude.exe'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer on Windows AppData', () => { + const result = detectSourceFromPath('C:/Users/test/AppData/Local/Claude/claude.exe'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer on Windows custom location', () => { + const result = detectSourceFromPath('E:/Tools/Claude/claude.exe'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer on Windows D: drive', () => { + const result = detectSourceFromPath('D:/Development/Claude/claude.exe'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer in user profile', () => { + const result = detectSourceFromPath('C:/Users/test/.claude/claude.exe'); + expect(result).toBe('native installer'); + }); + }); + + describe('Edge cases and special characters', () => { + it('should handle @ symbols in paths correctly', () => { + const result = detectSourceFromPath('/Users/@developer/test/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('npm'); + }); + + it('should handle case sensitivity variations on Windows', () => { + const result = detectSourceFromPath('C:/USERS/TEST/APPDATA/ROAMING/NPM/NODE_MODULES/@ANTHROPIC-AI/CLAUDE-CODE/CLI.JS'); + expect(result).toBe('npm'); + }); + + it('should return PATH for unrecognized paths', () => { + const result = detectSourceFromPath('/some/random/path/claude'); + expect(result).toBe('PATH'); + }); + + it('should handle empty paths', () => { + const result = detectSourceFromPath(''); + expect(result).toBe('PATH'); + }); + + it('should handle relative paths', () => { + const result = detectSourceFromPath('./local/bin/claude'); + expect(result).toBe('PATH'); + }); + }); + }); + + describe('Cross-platform compatibility', () => { + it('should handle both forward and backward slashes', () => { + const forward = detectSourceFromPath('C:/Users/test/AppData/Local/Claude/claude.exe'); + const backward = detectSourceFromPath('C:\\Users\\test\\AppData\\Local\\Claude\\claude.exe'); + + expect(forward).toBe('native installer'); + expect(backward).toBe('native installer'); + }); + + it('should handle Windows drive letters', () => { + const drives = ['C:', 'D:', 'E:', 'Z:']; + drives.forEach(drive => { + const result = detectSourceFromPath(`${drive}/Program Files/Claude/claude.exe`); + expect(result).toBe('native installer'); + }); + }); + + it('should handle Unix-style absolute paths', () => { + const unixPaths = [ + '/usr/local/bin/claude', + '/opt/homebrew/bin/claude', + '/home/user/.local/bin/claude' + ]; + + unixPaths.forEach(path => { + const result = detectSourceFromPath(path); + expect(['Homebrew', 'native installer']).toContain(result); + }); + }); + }); + + describe('Version comparison', () => { + it('should compare versions correctly', () => { + expect(compareVersions('2.0.69', '2.0.68')).toBe(1); + expect(compareVersions('2.0.68', '2.0.69')).toBe(-1); + expect(compareVersions('2.0.69', '2.0.69')).toBe(0); + expect(compareVersions('2.1.0', '2.0.69')).toBe(1); + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + }); + + it('should handle malformed versions gracefully', () => { + expect(() => compareVersions('', '2.0.0')).not.toThrow(); + expect(() => compareVersions('invalid', '2.0.0')).not.toThrow(); + expect(() => compareVersions('2.0.0', '')).not.toThrow(); + }); + }); + + describe('Integration scenarios', () => { + it('should handle multiple installations scenario', () => { + const scenarios = [ + { path: '/Users/test/.bun/bin/claude', expected: 'Bun' }, + { path: '/opt/homebrew/bin/claude', expected: 'Homebrew' }, + { path: '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' }, + { path: 'C:/Program Files/Claude/claude.exe', expected: 'native installer' } + ]; + + scenarios.forEach(({ path, expected }) => { + const result = detectSourceFromPath(path); + expect(result).toBe(expected); + }); + }); + + it('should maintain 100% success rate on all standard installation patterns', () => { + const standardPatterns = [ + // npm (most common) + { path: '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' }, + { path: 'C:/Users/test/AppData/Roaming/npm/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' }, + + // bun (second most common) + { path: '/Users/test/.bun/bin/claude', expected: 'Bun' }, + { path: 'C:/Users/test/.bun/bin/claude', expected: 'Bun' }, + + // homebrew (macOS and Linux) + { path: '/opt/homebrew/bin/claude', expected: 'Homebrew' }, + { path: '/home/linuxbrew/.linuxbrew/bin/claude', expected: 'Homebrew' }, + { path: '/Users/test/.linuxbrew/bin/claude', expected: 'Homebrew' }, // LinuxBrew user installation + + // native installers + { path: 'C:/Program Files/Claude/claude.exe', expected: 'native installer' }, + { path: 'C:/Users/test/AppData/Local/Claude/claude.exe', expected: 'native installer' }, + { path: '/Users/test/.local/bin/claude', expected: 'native installer' } + ]; + + let passed = 0; + standardPatterns.forEach(({ path, expected }) => { + const result = detectSourceFromPath(path); + if (result === expected) passed++; + }); + + expect(passed).toBe(standardPatterns.length); + expect(passed / standardPatterns.length).toBe(1); // 100% success rate + }); + + it('should handle platform-specific /usr/local/bin/claude correctly', () => { + const originalPlatform = process.platform; + + // Test on macOS (should be Homebrew) + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + const macosResult = detectSourceFromPath('/usr/local/bin/claude'); + expect(macosResult).toBe('Homebrew'); + + // Test on Linux (should be native installer) + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + const linuxResult = detectSourceFromPath('/usr/local/bin/claude'); + expect(linuxResult).toBe('native installer'); + + // Test on Windows (should fallback to PATH) + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const windowsResult = detectSourceFromPath('/usr/local/bin/claude'); + expect(windowsResult).toBe('PATH'); + + // Restore original platform + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + }); + + describe('Real-world edge cases', () => { + it('should handle complex user scenarios', () => { + const edgeCases = [ + // User with npm aliased to bun + { path: '/Users/test/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' }, + + // Multiple package managers + { path: '/Users/test/.bun/bin/claude', expected: 'Bun' }, + { path: '/opt/homebrew/bin/claude', expected: 'Homebrew' }, + + // Custom installations + { path: '/opt/custom/claude/bin/claude', expected: 'PATH' }, + { path: '/usr/local/custom/bin/claude', expected: 'PATH' } + ]; + + edgeCases.forEach(({ path, expected }) => { + const result = detectSourceFromPath(path); + expect(result).toBe(expected); + }); + }); + + it('should handle path traversal and normalization', () => { + const pathNormalizationTests = [ + { input: '/opt/homebrew/bin/../lib/claude', expected: 'Homebrew' }, + { input: '/Users/test/.bun/bin/./claude', expected: 'Bun' }, + { input: 'C:/Users/test/../test/AppData/Local/Claude/claude.exe', expected: 'native installer' } + ]; + + pathNormalizationTests.forEach(({ input, expected }) => { + const result = detectSourceFromPath(input); + expect(result).toBe(expected); + }); + }); + }); +}); \ No newline at end of file diff --git a/scripts/env-wrapper.cjs b/scripts/env-wrapper.cjs new file mode 100755 index 00000000..2ec562af --- /dev/null +++ b/scripts/env-wrapper.cjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * Cross-platform environment wrapper for happy CLI + * Sets HAPPY_HOME_DIR and provides visual feedback + * + * Usage: node scripts/env-wrapper.js [...args] + * + * Variants: + * - stable: Production-ready version using ~/.happy/ + * - dev: Development version using ~/.happy-dev/ + * + * Examples: + * node scripts/env-wrapper.js stable daemon start + * node scripts/env-wrapper.js dev auth login + */ + +const { spawn } = require('child_process'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const VARIANTS = { + stable: { + homeDir: path.join(os.homedir(), '.happy'), + color: '\x1b[32m', // Green + label: 'āœ… STABLE', + serverUrl: process.env.HAPPY_SERVER_URL || 'https://api.cluster-fluster.com' + }, + dev: { + homeDir: path.join(os.homedir(), '.happy-dev'), + color: '\x1b[33m', // Yellow + label: 'šŸ”§ DEV', + serverUrl: process.env.HAPPY_SERVER_URL || 'https://api.cluster-fluster.com' + } +}; + +const variant = process.argv[2]; +const command = process.argv[3]; +const args = process.argv.slice(4); + +if (!variant || !VARIANTS[variant]) { + console.error('Usage: node scripts/env-wrapper.js [...args]'); + console.error(''); + console.error('Variants:'); + console.error(' stable - Production-ready version (data: ~/.happy/)'); + console.error(' dev - Development version (data: ~/.happy-dev/)'); + console.error(''); + console.error('Examples:'); + console.error(' node scripts/env-wrapper.js stable daemon start'); + console.error(' node scripts/env-wrapper.js dev auth login'); + process.exit(1); +} + +const config = VARIANTS[variant]; + +// Create home directory if it doesn't exist +if (!fs.existsSync(config.homeDir)) { + fs.mkdirSync(config.homeDir, { recursive: true }); +} + +// Visual feedback +console.log(`${config.color}${config.label}\x1b[0m Happy CLI (data: ${config.homeDir})`); + +// Set environment and execute command +const env = { + ...process.env, + HAPPY_HOME_DIR: config.homeDir, + HAPPY_SERVER_URL: config.serverUrl, + HAPPY_VARIANT: variant, // For internal validation +}; + +const binPath = path.join(__dirname, '..', 'bin', 'happy.mjs'); +const proc = spawn('node', [binPath, command, ...args], { + env, + stdio: 'inherit', + shell: process.platform === 'win32' +}); + +proc.on('exit', (code) => process.exit(code || 0)); diff --git a/scripts/link-dev.cjs b/scripts/link-dev.cjs new file mode 100644 index 00000000..69eb92a5 --- /dev/null +++ b/scripts/link-dev.cjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +/** + * link-dev.cjs - Create symlink for happy-dev only + * + * This script creates a symlink for the happy-dev command pointing to the local + * development version, while leaving the stable npm version of `happy` untouched. + * + * Usage: yarn link:dev + * + * What it does: + * 1. Finds the global npm bin directory + * 2. Creates/updates a symlink: happy-dev -> ./bin/happy-dev.mjs + * + * To undo: yarn unlink:dev + */ + +const { execFileSync } = require('child_process'); +const { join, dirname } = require('path'); +const fs = require('fs'); + +const projectRoot = dirname(__dirname); +const binSource = join(projectRoot, 'bin', 'happy-dev.mjs'); + +// Get the action from command line args +const action = process.argv[2] || 'link'; + +function getGlobalBinDir() { + // Try npm global bin first using execFileSync (safer than execSync) + try { + const npmBin = execFileSync('npm', ['bin', '-g'], { encoding: 'utf8' }).trim(); + if (fs.existsSync(npmBin)) { + return npmBin; + } + } catch (e) { + // Fall through to alternatives + } + + // Common locations by platform + if (process.platform === 'darwin') { + // macOS with Homebrew Node (Apple Silicon) + const homebrewBin = '/opt/homebrew/bin'; + if (fs.existsSync(homebrewBin)) { + return homebrewBin; + } + // Intel Mac Homebrew + const homebrewUsrBin = '/usr/local/bin'; + if (fs.existsSync(homebrewUsrBin)) { + return homebrewUsrBin; + } + } + + // Fallback to /usr/local/bin + return '/usr/local/bin'; +} + +function link() { + const globalBin = getGlobalBinDir(); + const binTarget = join(globalBin, 'happy-dev'); + + console.log('Creating symlink for happy-dev...'); + console.log(` Source: ${binSource}`); + console.log(` Target: ${binTarget}`); + + // Check if source exists + if (!fs.existsSync(binSource)) { + console.error(`\nāŒ Error: ${binSource} does not exist.`); + console.error(" Run 'yarn build' first to compile the project."); + process.exit(1); + } + + // Remove existing symlink or file + try { + const stat = fs.lstatSync(binTarget); + if (stat.isSymbolicLink() || stat.isFile()) { + fs.unlinkSync(binTarget); + console.log(` Removed existing: ${binTarget}`); + } + } catch (e) { + // File doesn't exist, that's fine + } + + // Create the symlink + try { + fs.symlinkSync(binSource, binTarget); + console.log('\nāœ… Successfully linked happy-dev to local development version'); + console.log('\nNow you can use:'); + console.log(' happy → stable npm version (unchanged)'); + console.log(' happy-dev → local development version'); + console.log('\nTo undo: yarn unlink:dev'); + } catch (e) { + if (e.code === 'EACCES') { + console.error('\nāŒ Permission denied. Try running with sudo:'); + console.error(' sudo yarn link:dev'); + } else { + console.error(`\nāŒ Error creating symlink: ${e.message}`); + } + process.exit(1); + } +} + +function unlink() { + const globalBin = getGlobalBinDir(); + const binTarget = join(globalBin, 'happy-dev'); + + console.log('Removing happy-dev symlink...'); + + try { + const stat = fs.lstatSync(binTarget); + if (stat.isSymbolicLink()) { + const linkTarget = fs.readlinkSync(binTarget); + if (linkTarget === binSource || linkTarget.includes('happy-cli')) { + fs.unlinkSync(binTarget); + console.log('\nāœ… Removed happy-dev development symlink'); + console.log('\nTo restore npm version: npm install -g happy-coder'); + } else { + console.log(`\nāš ļø happy-dev symlink points elsewhere: ${linkTarget}`); + console.log(' Not removing. Remove manually if needed.'); + } + } else { + console.log(`\nāš ļø ${binTarget} exists but is not a symlink.`); + console.log(' Not removing. This may be the npm-installed version.'); + } + } catch (e) { + if (e.code === 'ENOENT') { + console.log("\nāœ… happy-dev symlink doesn't exist (already removed or never created)"); + } else if (e.code === 'EACCES') { + console.error('\nāŒ Permission denied. Try running with sudo:'); + console.error(' sudo yarn unlink:dev'); + process.exit(1); + } else { + console.error(`\nāŒ Error: ${e.message}`); + process.exit(1); + } + } +} + +// Main +if (action === 'unlink') { + unlink(); +} else { + link(); +} diff --git a/scripts/ripgrep_launcher.cjs b/scripts/ripgrep_launcher.cjs index 648277ab..ab00d9a8 100644 --- a/scripts/ripgrep_launcher.cjs +++ b/scripts/ripgrep_launcher.cjs @@ -3,13 +3,166 @@ /** * Ripgrep runner - executed as a subprocess to run the native module * This file is intentionally written in CommonJS to avoid ESM complexities + * + * Updated with graceful fallback chain for runtime compatibility: + * - Node.js: Try native addon first, fall back to binary + * - Bun: Use binary or system ripgrep directly + * - All runtimes: Cross-platform system detection + * - Fallback: Mock implementation with helpful guidance */ const path = require('path'); +const fs = require('fs'); -// Load the native module from unpacked directory -const modulePath = path.join(__dirname, '..', 'tools', 'unpacked', 'ripgrep.node'); -const ripgrepNative = require(modulePath); +// Runtime detection (minimal, focused) +function detectRuntime() { + if (typeof Bun !== 'undefined') return 'bun'; + if (typeof Deno !== 'undefined') return 'deno'; + if (process?.versions?.bun) return 'bun'; + if (process?.versions?.deno) return 'deno'; + if (process?.versions?.node) return 'node'; + return 'unknown'; +} + +// Find ripgrep in system PATH (cross-platform) +function findSystemRipgrep() { + const { execFileSync } = require('child_process'); + + // Platform-specific commands to find ripgrep + const commands = [ + // Windows: Use where command + process.platform === 'win32' && { cmd: 'where', args: ['rg'] }, + // Unix-like: Use which command + process.platform !== 'win32' && { cmd: 'which', args: ['rg'] } + ].filter(Boolean); + + for (const { cmd, args } of commands) { + try { + const result = execFileSync(cmd, args, { + encoding: 'utf8', + stdio: 'ignore' + }); + + if (result) { + const paths = result.trim().split('\n').filter(Boolean); + if (paths.length > 0) { + return paths[0].trim(); + } + } + } catch { + // Command failed, try next one + continue; + } + } + + // Fallback: Try common installation paths directly + const commonPaths = []; + if (process.platform === 'win32') { + commonPaths.push( + 'C:\\Program Files\\ripgrep\\rg.exe', + 'C:\\Program Files (x86)\\ripgrep\\rg.exe' + ); + } else if (process.platform === 'darwin') { + commonPaths.push( + '/opt/homebrew/bin/rg', + '/usr/local/bin/rg' + ); + } else if (process.platform === 'linux') { + commonPaths.push( + '/usr/bin/rg', + '/usr/local/bin/rg', + '/opt/homebrew/bin/rg' + ); + } + + for (const testPath of commonPaths) { + if (fs.existsSync(testPath)) { + return testPath; + } + } + + return null; +} + +// Create wrapper that mimics native addon interface +function createRipgrepWrapper(binaryPath) { + return { + ripgrepMain: (args) => { + const { spawnSync } = require('child_process'); + const result = spawnSync(binaryPath, args, { + stdio: 'inherit', + cwd: process.cwd() + }); + return result.status || 0; + } + }; +} + +// Create mock that doesn't crash but provides useful feedback +function createMockRipgrep() { + return { + ripgrepMain: (args) => { + if (args.includes('--version')) { + console.log('ripgrep 0.0.0 (mock)'); + return 0; + } + + console.error('Search functionality unavailable without ripgrep'); + console.error('See installation instructions above'); + return 1; + } + }; +} + +// Load ripgrep with graceful fallback chain +function loadRipgrepNative() { + const runtime = detectRuntime(); + const toolsDir = path.join(__dirname, '..', 'tools', 'unpacked'); + const nativePath = path.join(toolsDir, 'ripgrep.node'); + const binaryPath = path.join(toolsDir, 'rg'); + + // Try Node.js native addon first (preserves existing behavior) + if (runtime === 'node') { + try { + return require(nativePath); + } catch (error) { + console.warn('Failed to load ripgrep native addon:', error.message); + console.warn('Falling back to ripgrep binary...'); + // Fall through to binary fallback + } + } + + // Bun or Node.js fallback: Try system ripgrep + const systemRipgrep = findSystemRipgrep(); + if (systemRipgrep) { + console.info(`Using system ripgrep: ${systemRipgrep}`); + return createRipgrepWrapper(systemRipgrep); + } + + // Local binary fallback + if (fs.existsSync(binaryPath)) { + console.info('Using packaged ripgrep binary'); + return createRipgrepWrapper(binaryPath); + } + + // Final fallback: Return mock implementation that provides helpful guidance + console.warn('\nāš ļø ripgrep not available - search functionality limited'); + console.warn('Install ripgrep for full functionality:'); + + if (process.platform === 'win32') { + console.warn(' • Windows: winget install BurntSushi.ripgrep'); + console.warn(' • Or download from: https://github.com/BurntSushi/ripgrep/releases'); + } else { + console.warn(' • macOS/Linux: brew install ripgrep'); + console.warn(' • npm: npm install -g @silentsilas/ripgrep-bin'); + } + console.warn(''); + + return createMockRipgrep(); +} + +// Load ripgrep implementation +const ripgrepImplementation = loadRipgrepNative(); // Get arguments from command line (skip node and script name) const args = process.argv.slice(2); @@ -23,9 +176,9 @@ try { process.exit(1); } -// Run ripgrep +// Run ripgrep using the loaded implementation try { - const exitCode = ripgrepNative.ripgrepMain(parsedArgs); + const exitCode = ripgrepImplementation.ripgrepMain(parsedArgs); process.exit(exitCode); } catch (error) { console.error('Ripgrep error:', error.message); diff --git a/scripts/setup-dev.cjs b/scripts/setup-dev.cjs new file mode 100755 index 00000000..c0b2d18c --- /dev/null +++ b/scripts/setup-dev.cjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * One-command setup for development environment + * Creates directories, shows next steps + * + * Run: npm run setup:dev + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const STABLE_DIR = path.join(os.homedir(), '.happy'); +const DEV_DIR = path.join(os.homedir(), '.happy-dev'); + +console.log('šŸ”§ Setting up happy-cli development environment...\n'); + +// Create directories +[STABLE_DIR, DEV_DIR].forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + console.log(`āœ… Created: ${dir}`); + } else { + console.log(`ā„¹ļø Already exists: ${dir}`); + } +}); + +// Create .envrc for direnv users (optional) +const envrcContent = `# Happy CLI environment (for direnv users) +# Automatically sets HAPPY_HOME_DIR based on directory +# +# To use: cd to happy-cli-dev directory, run: direnv allow +export HAPPY_HOME_DIR="$HOME/.happy-dev" +export HAPPY_VARIANT="dev" +`; + +const envrcPath = path.join(__dirname, '..', '.envrc.example'); +if (!fs.existsSync(envrcPath)) { + fs.writeFileSync(envrcPath, envrcContent); + console.log(`āœ… Created: .envrc.example (optional direnv configuration)`); +} else { + console.log(`ā„¹ļø Already exists: .envrc.example`); +} + +console.log('\n✨ Setup complete!\n'); +console.log('šŸ“‹ Next steps:\n'); +console.log('1. Authenticate with stable version:'); +console.log(' npm run stable auth login\n'); +console.log('2. Authenticate with dev version (can use same or different account):'); +console.log(' npm run dev auth login\n'); +console.log('3. Start daemons:'); +console.log(' npm run stable:daemon:start # Stable version'); +console.log(' npm run dev:daemon:start # Dev version\n'); +console.log('4. Check status:'); +console.log(' npm run stable:daemon:status'); +console.log(' npm run dev:daemon:status\n'); +console.log('šŸ’” All commands are in package.json scripts for easy discovery!'); diff --git a/src/api/api.ts b/src/api/api.ts index 8d1c0208..64fcdd4d 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -121,43 +121,67 @@ export class ApiClient { } // Create machine - const response = await axios.post( - `${configuration.serverUrl}/v1/machines`, - { - id: opts.machineId, - metadata: encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.metadata)), - daemonState: opts.daemonState ? encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.daemonState)) : undefined, - dataEncryptionKey: dataEncryptionKey ? encodeBase64(dataEncryptionKey) : undefined - }, - { - headers: { - 'Authorization': `Bearer ${this.credential.token}`, - 'Content-Type': 'application/json' + try { + const response = await axios.post( + `${configuration.serverUrl}/v1/machines`, + { + id: opts.machineId, + metadata: encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.metadata)), + daemonState: opts.daemonState ? encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.daemonState)) : undefined, + dataEncryptionKey: dataEncryptionKey ? encodeBase64(dataEncryptionKey) : undefined }, - timeout: 60000 // 1 minute timeout for very bad network connections + { + headers: { + 'Authorization': `Bearer ${this.credential.token}`, + 'Content-Type': 'application/json' + }, + timeout: 60000 // 1 minute timeout for very bad network connections + } + ); + + if (response.status !== 200) { + console.error(chalk.red(`[API] Failed to create machine: ${response.statusText}`)); + console.log(chalk.yellow(`[API] Failed to create machine: ${response.statusText}, most likely you have re-authenticated, but you still have a machine associated with the old account. Now we are trying to re-associate the machine with the new account. That is not allowed. Please run 'happy doctor clean' to clean up your happy state, and try your original command again. Please create an issue on github if this is causing you problems. We apologize for the inconvenience.`)); + process.exit(1); } - ); - if (response.status !== 200) { - console.error(chalk.red(`[API] Failed to create machine: ${response.statusText}`)); - console.log(chalk.yellow(`[API] Failed to create machine: ${response.statusText}, most likely you have re-authenticated, but you still have a machine associated with the old account. Now we are trying to re-associate the machine with the new account. That is not allowed. Please run 'happy doctor clean' to clean up your happy state, and try your original command again. Please create an issue on github if this is causing you problems. We apologize for the inconvenience.`)); - process.exit(1); - } + const raw = response.data.machine; + logger.debug(`[API] Machine ${opts.machineId} registered/updated with server`); + + // Return decrypted machine like we do for sessions + const machine: Machine = { + id: raw.id, + encryptionKey: encryptionKey, + encryptionVariant: encryptionVariant, + metadata: raw.metadata ? decrypt(encryptionKey, encryptionVariant, decodeBase64(raw.metadata)) : null, + metadataVersion: raw.metadataVersion || 0, + daemonState: raw.daemonState ? decrypt(encryptionKey, encryptionVariant, decodeBase64(raw.daemonState)) : null, + daemonStateVersion: raw.daemonStateVersion || 0, + }; + return machine; + } catch (error) { + // Handle 404 gracefully - server endpoint may not be available yet + if (axios.isAxiosError(error) && error.response?.status === 404) { + console.warn(chalk.yellow(`[API] Warning: Machine registration endpoint not available (404)`)); + console.warn(chalk.yellow(`[API] Continuing without machine registration. This is normal in development mode.`)); + logger.debug(`[API] Server: ${configuration.serverUrl}/v1/machines returned 404`); + + // Return a minimal machine object without server registration + const machine: Machine = { + id: opts.machineId, + encryptionKey: encryptionKey, + encryptionVariant: encryptionVariant, + metadata: opts.metadata, + metadataVersion: 0, + daemonState: opts.daemonState || null, + daemonStateVersion: 0, + }; + return machine; + } - const raw = response.data.machine; - logger.debug(`[API] Machine ${opts.machineId} registered/updated with server`); - - // Return decrypted machine like we do for sessions - const machine: Machine = { - id: raw.id, - encryptionKey: encryptionKey, - encryptionVariant: encryptionVariant, - metadata: raw.metadata ? decrypt(encryptionKey, encryptionVariant, decodeBase64(raw.metadata)) : null, - metadataVersion: raw.metadataVersion || 0, - daemonState: raw.daemonState ? decrypt(encryptionKey, encryptionVariant, decodeBase64(raw.daemonState)) : null, - daemonStateVersion: raw.daemonStateVersion || 0, - }; - return machine; + // For other errors, rethrow + throw error; + } } sessionSyncClient(session: Session): ApiSessionClient { diff --git a/src/api/apiMachine.ts b/src/api/apiMachine.ts index c923db04..b8e2b570 100644 --- a/src/api/apiMachine.ts +++ b/src/api/apiMachine.ts @@ -102,14 +102,14 @@ export class ApiMachineClient { }: MachineRpcHandlers) { // Register spawn session handler this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => { - const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token } = params || {}; + const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token, environmentVariables } = params || {}; logger.debug(`[API MACHINE] Spawning session with params: ${JSON.stringify(params)}`); if (!directory) { throw new Error('Directory is required'); } - const result = await spawnSession({ directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token }); + const result = await spawnSession({ directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token, environmentVariables }); switch (result.type) { case 'success': diff --git a/src/api/types.ts b/src/api/types.ts index ae0147e5..e1b4878d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,6 +1,19 @@ import { z } from 'zod' import { UsageSchema } from '@/claude/types' -import { PermissionMode } from '@/claude/loop' + +/** + * Permission mode type - includes both Claude and Codex modes + * Must match MessageMetaSchema.permissionMode enum values + * + * Claude modes: default, acceptEdits, bypassPermissions, plan + * Codex modes: read-only, safe-yolo, yolo + * + * When calling Claude SDK, Codex modes are mapped at the SDK boundary: + * - yolo → bypassPermissions + * - safe-yolo → default + * - read-only → default + */ +export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' /** * Usage data type from Claude @@ -229,7 +242,7 @@ export type SessionMessage = z.infer */ export const MessageMetaSchema = z.object({ sentFrom: z.string().optional(), // Source identifier - permissionMode: z.string().optional(), // Permission mode for this message + permissionMode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']).optional(), // Permission mode for this message model: z.string().nullable().optional(), // Model name for this message (null = reset) fallbackModel: z.string().nullable().optional(), // Fallback model for this message (null = reset) customSystemPrompt: z.string().nullable().optional(), // Custom system prompt for this message (null = reset) diff --git a/src/claude/claudeRemote.ts b/src/claude/claudeRemote.ts index 0f423ded..7f38022f 100644 --- a/src/claude/claudeRemote.ts +++ b/src/claude/claudeRemote.ts @@ -1,5 +1,6 @@ -import { EnhancedMode, PermissionMode } from "./loop"; -import { query, type QueryOptions as Options, type SDKMessage, type SDKSystemMessage, AbortError, SDKUserMessage } from '@/claude/sdk' +import { EnhancedMode } from "./loop"; +import { query, type QueryOptions, type SDKMessage, type SDKSystemMessage, AbortError, SDKUserMessage } from '@/claude/sdk' +import { mapToClaudeMode } from "./utils/permissionMode"; import { claudeCheckSession } from "./utils/claudeCheckSession"; import { join, resolve } from 'node:path'; import { projectPath } from "@/projectPath"; @@ -107,11 +108,11 @@ export async function claudeRemote(opts: { // Prepare SDK options let mode = initial.mode; - const sdkOptions: Options = { + const sdkOptions: QueryOptions = { cwd: opts.path, resume: startFrom ?? undefined, mcpServers: opts.mcpServers, - permissionMode: initial.mode.permissionMode === 'plan' ? 'plan' : 'default', + permissionMode: mapToClaudeMode(initial.mode.permissionMode), model: initial.mode.model, fallbackModel: initial.mode.fallbackModel, customSystemPrompt: initial.mode.customSystemPrompt ? initial.mode.customSystemPrompt + '\n\n' + systemPrompt : undefined, diff --git a/src/claude/loop.ts b/src/claude/loop.ts index 48da7800..3a2151e1 100644 --- a/src/claude/loop.ts +++ b/src/claude/loop.ts @@ -6,7 +6,10 @@ import { claudeLocalLauncher } from "./claudeLocalLauncher" import { claudeRemoteLauncher } from "./claudeRemoteLauncher" import { ApiClient } from "@/lib" -export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; +// Re-export permission mode type from api/types +// Single unified type with 7 modes - Codex modes mapped at SDK boundary +export type { PermissionMode } from "@/api/types" +import type { PermissionMode } from "@/api/types" export interface EnhancedMode { permissionMode: PermissionMode; diff --git a/src/claude/runClaude.ts b/src/claude/runClaude.ts index dc5d1982..42d91ffb 100644 --- a/src/claude/runClaude.ts +++ b/src/claude/runClaude.ts @@ -24,7 +24,7 @@ import { resolve } from 'node:path'; export interface StartOptions { model?: string - permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' + permissionMode?: PermissionMode startingMode?: 'local' | 'remote' shouldStartDaemon?: boolean claudeEnvVars?: Record @@ -40,12 +40,9 @@ export async function runClaude(credentials: Credentials, options: StartOptions logger.debugLargeJson('[START] Happy process started', getEnvironmentInfo()); logger.debug(`[START] Options: startedBy=${options.startedBy}, startingMode=${options.startingMode}`); - // Validate daemon spawn requirements + // Validate daemon spawn requirements - fail fast on invalid config if (options.startedBy === 'daemon' && options.startingMode === 'local') { - logger.debug('Daemon spawn requested with local mode - forcing remote mode'); - options.startingMode = 'remote'; - // TODO: Eventually we should error here instead of silently switching - // throw new Error('Daemon-spawned sessions cannot use local/interactive mode'); + throw new Error('Daemon-spawned sessions cannot use local/interactive mode. Use --happy-starting-mode remote or spawn sessions directly from terminal.'); } // Create session service @@ -155,7 +152,8 @@ export async function runClaude(credentials: Credentials, options: StartOptions })); // Forward messages to the queue - let currentPermissionMode = options.permissionMode; + // Permission modes: Use the unified 7-mode type, mapping happens at SDK boundary in claudeRemote.ts + let currentPermissionMode: PermissionMode | undefined = options.permissionMode; let currentModel = options.model; // Track current model state let currentFallbackModel: string | undefined = undefined; // Track current fallback model let currentCustomSystemPrompt: string | undefined = undefined; // Track current custom system prompt @@ -164,18 +162,12 @@ export async function runClaude(credentials: Credentials, options: StartOptions let currentDisallowedTools: string[] | undefined = undefined; // Track current disallowed tools session.onUserMessage((message) => { - // Resolve permission mode from meta - let messagePermissionMode = currentPermissionMode; + // Resolve permission mode from meta - pass through as-is, mapping happens at SDK boundary + let messagePermissionMode: PermissionMode | undefined = currentPermissionMode; if (message.meta?.permissionMode) { - const validModes: PermissionMode[] = ['default', 'acceptEdits', 'bypassPermissions', 'plan']; - if (validModes.includes(message.meta.permissionMode as PermissionMode)) { - messagePermissionMode = message.meta.permissionMode as PermissionMode; - currentPermissionMode = messagePermissionMode; - logger.debug(`[loop] Permission mode updated from user message to: ${currentPermissionMode}`); - - } else { - logger.debug(`[loop] Invalid permission mode received: ${message.meta.permissionMode}`); - } + messagePermissionMode = message.meta.permissionMode; + currentPermissionMode = messagePermissionMode; + logger.debug(`[loop] Permission mode updated from user message to: ${currentPermissionMode}`); } else { logger.debug(`[loop] User message received with no permission mode override, using current: ${currentPermissionMode}`); } diff --git a/src/claude/sdk/utils.ts b/src/claude/sdk/utils.ts index 773ceb6a..0602d5a8 100644 --- a/src/claude/sdk/utils.ts +++ b/src/claude/sdk/utils.ts @@ -9,6 +9,7 @@ import { existsSync, readFileSync } from 'node:fs' import { execSync } from 'node:child_process' import { homedir } from 'node:os' import { logger } from '@/ui/logger' +import { isBun } from '@/utils/runtime' /** * Get the directory path of the current module @@ -41,16 +42,17 @@ function getGlobalClaudeVersion(): string | null { /** * Create a clean environment without local node_modules/.bin in PATH * This ensures we find the global claude, not the local one + * Also removes conflicting Bun environment variables when running in Bun */ export function getCleanEnv(): NodeJS.ProcessEnv { const env = { ...process.env } const cwd = process.cwd() const pathSep = process.platform === 'win32' ? ';' : ':' const pathKey = process.platform === 'win32' ? 'Path' : 'PATH' - + // Also check for PATH on Windows (case can vary) const actualPathKey = Object.keys(env).find(k => k.toLowerCase() === 'path') || pathKey - + if (env[actualPathKey]) { // Remove any path that contains the current working directory (local node_modules/.bin) const cleanPath = env[actualPathKey]! @@ -64,7 +66,17 @@ export function getCleanEnv(): NodeJS.ProcessEnv { env[actualPathKey] = cleanPath logger.debug(`[Claude SDK] Cleaned PATH, removed local paths from: ${cwd}`) } - + + // Remove Bun-specific environment variables that can interfere with Node.js processes + if (isBun()) { + Object.keys(env).forEach(key => { + if (key.startsWith('BUN_')) { + delete env[key] + } + }) + logger.debug('[Claude SDK] Removed Bun-specific environment variables for Node.js compatibility') + } + return env } diff --git a/src/claude/utils/permissionMode.test.ts b/src/claude/utils/permissionMode.test.ts new file mode 100644 index 00000000..526f20b9 --- /dev/null +++ b/src/claude/utils/permissionMode.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { mapToClaudeMode } from './permissionMode'; +import type { PermissionMode } from '@/api/types'; + +describe('mapToClaudeMode', () => { + describe('Codex modes are mapped to Claude equivalents', () => { + it('maps yolo → bypassPermissions', () => { + expect(mapToClaudeMode('yolo')).toBe('bypassPermissions'); + }); + + it('maps safe-yolo → default', () => { + expect(mapToClaudeMode('safe-yolo')).toBe('default'); + }); + + it('maps read-only → default', () => { + expect(mapToClaudeMode('read-only')).toBe('default'); + }); + }); + + describe('Claude modes pass through unchanged', () => { + it('passes through default', () => { + expect(mapToClaudeMode('default')).toBe('default'); + }); + + it('passes through acceptEdits', () => { + expect(mapToClaudeMode('acceptEdits')).toBe('acceptEdits'); + }); + + it('passes through bypassPermissions', () => { + expect(mapToClaudeMode('bypassPermissions')).toBe('bypassPermissions'); + }); + + it('passes through plan', () => { + expect(mapToClaudeMode('plan')).toBe('plan'); + }); + }); + + describe('all 7 PermissionMode values are handled', () => { + const allModes: PermissionMode[] = [ + 'default', 'acceptEdits', 'bypassPermissions', 'plan', // Claude modes + 'read-only', 'safe-yolo', 'yolo' // Codex modes + ]; + + it('returns a valid Claude mode for every PermissionMode', () => { + const validClaudeModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan']; + + allModes.forEach(mode => { + const result = mapToClaudeMode(mode); + expect(validClaudeModes).toContain(result); + }); + }); + }); +}); diff --git a/src/claude/utils/permissionMode.ts b/src/claude/utils/permissionMode.ts new file mode 100644 index 00000000..204a7029 --- /dev/null +++ b/src/claude/utils/permissionMode.ts @@ -0,0 +1,26 @@ +import type { QueryOptions } from '@/claude/sdk'; +import type { PermissionMode } from '@/api/types'; + +/** Derived from SDK's QueryOptions - the modes Claude actually supports */ +export type ClaudeSdkPermissionMode = NonNullable; + +/** + * Map any PermissionMode (7 modes) to a Claude-compatible mode (4 modes) + * This is the ONLY place where Codex modes are mapped to Claude equivalents. + * + * Mapping: + * - yolo → bypassPermissions (both skip all permissions) + * - safe-yolo → default (ask for permissions) + * - read-only → default (Claude doesn't support read-only) + * + * Claude modes pass through unchanged: + * - default, acceptEdits, bypassPermissions, plan + */ +export function mapToClaudeMode(mode: PermissionMode): ClaudeSdkPermissionMode { + const codexToClaudeMap: Record = { + 'yolo': 'bypassPermissions', + 'safe-yolo': 'default', + 'read-only': 'default', + }; + return codexToClaudeMap[mode] ?? (mode as ClaudeSdkPermissionMode); +} diff --git a/src/codex/runCodex.ts b/src/codex/runCodex.ts index 62b4b5fd..cd096352 100644 --- a/src/codex/runCodex.ts +++ b/src/codex/runCodex.ts @@ -63,7 +63,8 @@ export async function runCodex(opts: { credentials: Credentials; startedBy?: 'daemon' | 'terminal'; }): Promise { - type PermissionMode = 'default' | 'read-only' | 'safe-yolo' | 'yolo'; + // Use shared PermissionMode type for cross-agent compatibility + type PermissionMode = import('@/api/types').PermissionMode; interface EnhancedMode { permissionMode: PermissionMode; model?: string; @@ -142,21 +143,17 @@ export async function runCodex(opts: { })); // Track current overrides to apply per message - let currentPermissionMode: PermissionMode | undefined = undefined; + // Use shared PermissionMode type from api/types for cross-agent compatibility + let currentPermissionMode: import('@/api/types').PermissionMode | undefined = undefined; let currentModel: string | undefined = undefined; session.onUserMessage((message) => { - // Resolve permission mode (validate) + // Resolve permission mode (accept all modes, will be mapped in switch statement) let messagePermissionMode = currentPermissionMode; if (message.meta?.permissionMode) { - const validModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; - if (validModes.includes(message.meta.permissionMode as PermissionMode)) { - messagePermissionMode = message.meta.permissionMode as PermissionMode; - currentPermissionMode = messagePermissionMode; - logger.debug(`[Codex] Permission mode updated from user message to: ${currentPermissionMode}`); - } else { - logger.debug(`[Codex] Invalid permission mode received: ${message.meta.permissionMode}`); - } + messagePermissionMode = message.meta.permissionMode as import('@/api/types').PermissionMode; + currentPermissionMode = messagePermissionMode; + logger.debug(`[Codex] Permission mode updated from user message to: ${currentPermissionMode}`); } else { logger.debug(`[Codex] User message received with no permission mode override, using current: ${currentPermissionMode ?? 'default (effective)'}`); } @@ -619,18 +616,30 @@ export async function runCodex(opts: { // Map permission mode to approval policy and sandbox for startSession const approvalPolicy = (() => { switch (message.mode.permissionMode) { - case 'default': return 'untrusted' as const; - case 'read-only': return 'never' as const; - case 'safe-yolo': return 'on-failure' as const; - case 'yolo': return 'on-failure' as const; + // Codex native modes + case 'default': return 'untrusted' as const; // Ask for non-trusted commands + case 'read-only': return 'never' as const; // Never ask, read-only enforced by sandbox + case 'safe-yolo': return 'on-failure' as const; // Auto-run, ask only on failure + case 'yolo': return 'on-failure' as const; // Auto-run, ask only on failure + // Defensive fallback for Claude-specific modes (backward compatibility) + case 'bypassPermissions': return 'on-failure' as const; // Full access: map to yolo behavior + case 'acceptEdits': return 'on-request' as const; // Let model decide (closest to auto-approve edits) + case 'plan': return 'untrusted' as const; // Conservative: ask for non-trusted + default: return 'untrusted' as const; // Safe fallback } })(); const sandbox = (() => { switch (message.mode.permissionMode) { - case 'default': return 'workspace-write' as const; - case 'read-only': return 'read-only' as const; - case 'safe-yolo': return 'workspace-write' as const; - case 'yolo': return 'danger-full-access' as const; + // Codex native modes + case 'default': return 'workspace-write' as const; // Can write in workspace + case 'read-only': return 'read-only' as const; // Read-only filesystem + case 'safe-yolo': return 'workspace-write' as const; // Can write in workspace + case 'yolo': return 'danger-full-access' as const; // Full system access + // Defensive fallback for Claude-specific modes + case 'bypassPermissions': return 'danger-full-access' as const; // Full access: map to yolo + case 'acceptEdits': return 'workspace-write' as const; // Can edit files in workspace + case 'plan': return 'workspace-write' as const; // Can write for planning + default: return 'workspace-write' as const; // Safe default } })(); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index f090a65c..35cbe8a5 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -23,9 +23,6 @@ export async function handleAuthCommand(args: string[]): Promise { case 'logout': await handleAuthLogout(); break; - // case 'backup': - // await handleAuthShowBackup(); - // break; case 'status': await handleAuthStatus(); break; @@ -42,9 +39,8 @@ ${chalk.bold('happy auth')} - Authentication management ${chalk.bold('Usage:')} happy auth login [--force] Authenticate with Happy - happy auth logout Remove authentication and machine data + happy auth logout Remove authentication and machine data happy auth status Show authentication status - happy auth show-backup Display backup key for mobile/web clients happy auth help Show this help message ${chalk.bold('Options:')} @@ -163,42 +159,6 @@ async function handleAuthLogout(): Promise { } } -// async function handleAuthShowBackup(): Promise { -// const credentials = await readCredentials(); -// const settings = await readSettings(); - -// if (!credentials) { -// console.log(chalk.yellow('Not authenticated')); -// console.log(chalk.gray('Run "happy auth login" to authenticate first')); -// return; -// } - -// // Format the backup key exactly like the mobile client expects -// // Mobile client uses formatSecretKeyForBackup which converts to base32 with dashes -// const formattedBackupKey = formatSecretKeyForBackup(credentials.encryption.secret); - -// console.log(chalk.bold('\nšŸ“± Backup Key\n')); - -// // Display in the format XXXXX-XXXXX-XXXXX-... that mobile expects -// console.log(chalk.cyan('Your backup key:')); -// console.log(chalk.bold(formattedBackupKey)); -// console.log(''); - -// console.log(chalk.cyan('Machine Information:')); -// console.log(` Machine ID: ${settings?.machineId || 'not set'}`); -// console.log(` Host: ${os.hostname()}`); -// console.log(''); - -// console.log(chalk.bold('How to use this backup key:')); -// console.log(chalk.gray('• In Happy mobile app: Go to restore/link device and enter this key')); -// console.log(chalk.gray('• This key format matches what the mobile app expects')); -// console.log(chalk.gray('• You can type it with or without dashes - the app will normalize it')); -// console.log(chalk.gray('• Common typos (0→O, 1→I) are automatically corrected')); -// console.log(''); - -// console.log(chalk.yellow('āš ļø Keep this key secure - it provides full access to your account')); -// } - async function handleAuthStatus(): Promise { const credentials = await readCredentials(); const settings = await readSettings(); diff --git a/src/configuration.ts b/src/configuration.ts index 5da629f5..830eb300 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -56,6 +56,23 @@ class Configuration { this.currentCliVersion = packageJson.version + // Validate variant configuration + const variant = process.env.HAPPY_VARIANT || 'stable' + if (variant === 'dev' && !this.happyHomeDir.includes('dev')) { + console.warn('āš ļø WARNING: HAPPY_VARIANT=dev but HAPPY_HOME_DIR does not contain "dev"') + console.warn(` Current: ${this.happyHomeDir}`) + console.warn(` Expected: Should contain "dev" (e.g., ~/.happy-dev)`) + } + + // Visual indicator on CLI startup (only if not daemon process to avoid log clutter) + if (!this.isDaemonProcess) { + if (variant === 'dev') { + console.log('\x1b[33mšŸ”§ DEV MODE\x1b[0m - Data: ' + this.happyHomeDir) + } else { + console.log('\x1b[32māœ… STABLE MODE\x1b[0m - Data: ' + this.happyHomeDir) + } + } + if (!existsSync(this.happyHomeDir)) { mkdirSync(this.happyHomeDir, { recursive: true }) } diff --git a/src/daemon/run.ts b/src/daemon/run.ts index 6d3be511..7f2def8c 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -13,13 +13,15 @@ import { startCaffeinate, stopCaffeinate } from '@/utils/caffeinate'; import packageJson from '../../package.json'; import { getEnvironmentInfo } from '@/ui/doctor'; import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; -import { writeDaemonState, DaemonLocallyPersistedState, readDaemonState, acquireDaemonLock, releaseDaemonLock } from '@/persistence'; +import { writeDaemonState, DaemonLocallyPersistedState, readDaemonState, acquireDaemonLock, releaseDaemonLock, readSettings, getActiveProfile, getEnvironmentVariables, validateProfileForAgent, getProfileEnvironmentVariables } from '@/persistence'; import { cleanupDaemonState, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './controlClient'; import { startDaemonControlServer } from './controlServer'; import { readFileSync } from 'fs'; import { join } from 'path'; import { projectPath } from '@/projectPath'; +import { getTmuxUtilities, isTmuxAvailable, parseTmuxSessionIdentifier, formatTmuxSessionIdentifier } from '@/utils/tmux'; +import { expandEnvironmentVariables } from '@/utils/expandEnvVars'; // Prepare initial metadata export const initialMachineMetadata: MachineMetadata = { @@ -31,6 +33,37 @@ export const initialMachineMetadata: MachineMetadata = { happyLibDir: projectPath() }; +// Get environment variables for a profile, filtered for agent compatibility +async function getProfileEnvironmentVariablesForAgent( + profileId: string, + agentType: 'claude' | 'codex' +): Promise> { + try { + const settings = await readSettings(); + const profile = settings.profiles.find(p => p.id === profileId); + + if (!profile) { + logger.debug(`[DAEMON RUN] Profile ${profileId} not found`); + return {}; + } + + // Check if profile is compatible with the agent + if (!validateProfileForAgent(profile, agentType)) { + logger.debug(`[DAEMON RUN] Profile ${profileId} not compatible with agent ${agentType}`); + return {}; + } + + // Get environment variables from profile (new schema) + const envVars = getProfileEnvironmentVariables(profile); + + logger.debug(`[DAEMON RUN] Loaded ${Object.keys(envVars).length} environment variables from profile ${profileId} for agent ${agentType}`); + return envVars; + } catch (error) { + logger.debug('[DAEMON RUN] Failed to get profile environment variables:', error); + return {}; + } +} + export async function startDaemon(): Promise { // We don't have cleanup function at the time of server construction // Control flow is: @@ -234,8 +267,13 @@ export async function startDaemon(): Promise { try { - // Resolve authentication token if provided - let extraEnv: Record = {}; + // Build environment variables with explicit precedence layers: + // Layer 1 (base): Authentication tokens - protected, cannot be overridden + // Layer 2 (middle): Profile environment variables - GUI profile OR CLI local profile + // Layer 3 (top): Auth tokens again to ensure they're never overridden + + // Layer 1: Resolve authentication token if provided + const authEnv: Record = {}; if (options.token) { if (options.agent === 'codex') { @@ -246,105 +284,287 @@ export async function startDaemon(): Promise { fs.writeFile(join(codexHomeDir.name, 'auth.json'), options.token); // Set the environment variable for Codex - extraEnv = { - CODEX_HOME: codexHomeDir.name - }; + authEnv.CODEX_HOME = codexHomeDir.name; } else { // Assuming claude - extraEnv = { - CLAUDE_CODE_OAUTH_TOKEN: options.token - }; + authEnv.CLAUDE_CODE_OAUTH_TOKEN = options.token; } } - // Construct arguments for the CLI - const args = [ - options.agent === 'claude' ? 'claude' : 'codex', - '--happy-starting-mode', 'remote', - '--started-by', 'daemon' - ]; - - // TODO: In future, sessionId could be used with --resume to continue existing sessions - // For now, we ignore it - each spawn creates a new session - const happyProcess = spawnHappyCLI(args, { - cwd: directory, - detached: true, // Sessions stay alive when daemon stops - stdio: ['ignore', 'pipe', 'pipe'], // Capture stdout/stderr for debugging - env: { - ...process.env, - ...extraEnv + // Layer 2: Profile environment variables + // Priority: GUI-provided profile > CLI local active profile > none + let profileEnv: Record = {}; + + if (options.environmentVariables && Object.keys(options.environmentVariables).length > 0) { + // GUI provided profile environment variables - highest priority for profile settings + profileEnv = options.environmentVariables; + logger.info(`[DAEMON RUN] Using GUI-provided profile environment variables (${Object.keys(profileEnv).length} vars)`); + logger.debug(`[DAEMON RUN] GUI profile env var keys: ${Object.keys(profileEnv).join(', ')}`); + } else { + // Fallback to CLI local active profile + try { + const settings = await readSettings(); + if (settings.activeProfileId) { + logger.debug(`[DAEMON RUN] No GUI profile provided, loading CLI local active profile: ${settings.activeProfileId}`); + + // Get profile environment variables filtered for agent compatibility + profileEnv = await getProfileEnvironmentVariablesForAgent( + settings.activeProfileId, + options.agent || 'claude' + ); + + logger.debug(`[DAEMON RUN] Loaded ${Object.keys(profileEnv).length} environment variables from CLI local profile for agent ${options.agent || 'claude'}`); + logger.debug(`[DAEMON RUN] CLI profile env var keys: ${Object.keys(profileEnv).join(', ')}`); + } else { + logger.debug('[DAEMON RUN] No CLI local active profile set'); + } + } catch (error) { + logger.debug('[DAEMON RUN] Failed to load CLI local profile environment variables:', error); + // Continue without profile env vars - this is not a fatal error } + } + + // Final merge: Profile vars first, then auth (auth takes precedence to protect authentication) + let extraEnv = { ...profileEnv, ...authEnv }; + logger.debug(`[DAEMON RUN] Final environment variable keys (before expansion) (${Object.keys(extraEnv).length}): ${Object.keys(extraEnv).join(', ')}`); + + // Expand ${VAR} references from daemon's process.env + // This ensures variable substitution works in both tmux and non-tmux modes + // Example: ANTHROPIC_AUTH_TOKEN="${Z_AI_AUTH_TOKEN}" → ANTHROPIC_AUTH_TOKEN="sk-real-key" + extraEnv = expandEnvironmentVariables(extraEnv, process.env); + logger.debug(`[DAEMON RUN] After variable expansion: ${Object.keys(extraEnv).join(', ')}`); + + // Fail-fast validation: Check that any auth variables present are fully expanded + // Only validate variables that are actually set (different agents need different auth) + const potentialAuthVars = ['ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'OPENAI_API_KEY', 'CODEX_HOME', 'AZURE_OPENAI_API_KEY', 'TOGETHER_API_KEY']; + const unexpandedAuthVars = potentialAuthVars.filter(varName => { + const value = extraEnv[varName]; + // Only fail if variable IS SET and contains unexpanded ${VAR} references + return value && typeof value === 'string' && value.includes('${'); }); - // Log output for debugging - if (process.env.DEBUG) { - happyProcess.stdout?.on('data', (data) => { - logger.debug(`[DAEMON RUN] Child stdout: ${data.toString()}`); + if (unexpandedAuthVars.length > 0) { + // Extract the specific missing variable names from unexpanded references + const missingVarDetails = unexpandedAuthVars.map(authVar => { + const value = extraEnv[authVar]; + const unresolvedMatch = value?.match(/\$\{([A-Z_][A-Z0-9_]*)(:-[^}]*)?\}/); + const missingVar = unresolvedMatch ? unresolvedMatch[1] : 'unknown'; + return `${authVar} references \${${missingVar}} which is not defined`; }); - happyProcess.stderr?.on('data', (data) => { - logger.debug(`[DAEMON RUN] Child stderr: ${data.toString()}`); - }); - } - if (!happyProcess.pid) { - logger.debug('[DAEMON RUN] Failed to spawn process - no PID returned'); + const errorMessage = `Authentication will fail - environment variables not found in daemon: ${missingVarDetails.join('; ')}. ` + + `Ensure these variables are set in the daemon's environment (not just your shell) before starting sessions.`; + logger.warn(`[DAEMON RUN] ${errorMessage}`); return { type: 'error', - errorMessage: 'Failed to spawn Happy process - no PID returned' + errorMessage }; } - logger.debug(`[DAEMON RUN] Spawned process with PID ${happyProcess.pid}`); + // Check if tmux is available and should be used + const tmuxAvailable = await isTmuxAvailable(); + let useTmux = tmuxAvailable; - const trackedSession: TrackedSession = { - startedBy: 'daemon', - pid: happyProcess.pid, - childProcess: happyProcess, - directoryCreated, - message: directoryCreated ? `The path '${directory}' did not exist. We created a new folder and spawned a new session there.` : undefined - }; + // Get tmux session name from environment variables (now set by profile system) + // Empty string means "use current/most recent session" (tmux default behavior) + let tmuxSessionName: string | undefined = extraEnv.TMUX_SESSION_NAME; - pidToTrackedSession.set(happyProcess.pid, trackedSession); + // If tmux is not available or session name is explicitly undefined, fall back to regular spawning + // Note: Empty string is valid (means use current/most recent tmux session) + if (!tmuxAvailable || tmuxSessionName === undefined) { + useTmux = false; + if (tmuxSessionName !== undefined) { + logger.debug(`[DAEMON RUN] tmux session name specified but tmux not available, falling back to regular spawning`); + } + } - happyProcess.on('exit', (code, signal) => { - logger.debug(`[DAEMON RUN] Child PID ${happyProcess.pid} exited with code ${code}, signal ${signal}`); - if (happyProcess.pid) { - onChildExited(happyProcess.pid); + if (useTmux && tmuxSessionName !== undefined) { + // Try to spawn in tmux session + const sessionDesc = tmuxSessionName || 'current/most recent session'; + logger.debug(`[DAEMON RUN] Attempting to spawn session in tmux: ${sessionDesc}`); + + const tmux = getTmuxUtilities(tmuxSessionName); + + // Construct command for the CLI + const cliPath = join(projectPath(), 'dist', 'index.mjs'); + const agent = options.agent === 'claude' ? 'claude' : 'codex'; + const fullCommand = `node --no-warnings --no-deprecation ${cliPath} ${agent} --happy-starting-mode remote --started-by daemon`; + + // Spawn in tmux with environment variables + // IMPORTANT: Pass complete environment (process.env + extraEnv) because: + // 1. tmux sessions need daemon's expanded auth variables (e.g., ANTHROPIC_AUTH_TOKEN) + // 2. Regular spawn uses env: { ...process.env, ...extraEnv } + // 3. tmux needs explicit environment via -e flags to ensure all variables are available + const windowName = `happy-${Date.now()}-${agent}`; + const tmuxEnv: Record = {}; + + // Add all daemon environment variables (filtering out undefined) + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + tmuxEnv[key] = value; + } } - }); - happyProcess.on('error', (error) => { - logger.debug(`[DAEMON RUN] Child process error:`, error); - if (happyProcess.pid) { - onChildExited(happyProcess.pid); + // Add extra environment variables (these should already be filtered) + Object.assign(tmuxEnv, extraEnv); + + const tmuxResult = await tmux.spawnInTmux([fullCommand], { + sessionName: tmuxSessionName, + windowName: windowName, + cwd: directory + }, tmuxEnv); // Pass complete environment for tmux session + + if (tmuxResult.success) { + logger.debug(`[DAEMON RUN] Successfully spawned in tmux session: ${tmuxResult.sessionId}, PID: ${tmuxResult.pid}`); + + // Validate we got a PID from tmux + if (!tmuxResult.pid) { + throw new Error('Tmux window created but no PID returned'); + } + + // Create a tracked session for tmux windows - now we have the real PID! + const trackedSession: TrackedSession = { + startedBy: 'daemon', + pid: tmuxResult.pid, // Real PID from tmux -P flag + tmuxSessionId: tmuxResult.sessionId, + directoryCreated, + message: directoryCreated + ? `The path '${directory}' did not exist. We created a new folder and spawned a new session in tmux session '${tmuxSessionName}'. Use 'tmux attach -t ${tmuxSessionName}' to view the session.` + : `Spawned new session in tmux session '${tmuxSessionName}'. Use 'tmux attach -t ${tmuxSessionName}' to view the session.` + }; + + // Add to tracking map so webhook can find it later + pidToTrackedSession.set(tmuxResult.pid, trackedSession); + + // Wait for webhook to populate session with happySessionId (exact same as regular flow) + logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${tmuxResult.pid} (tmux)`); + + return new Promise((resolve) => { + // Set timeout for webhook (same as regular flow) + const timeout = setTimeout(() => { + pidToAwaiter.delete(tmuxResult.pid!); + logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${tmuxResult.pid} (tmux)`); + resolve({ + type: 'error', + errorMessage: `Session webhook timeout for PID ${tmuxResult.pid} (tmux)` + }); + }, 15_000); // Same timeout as regular sessions + + // Register awaiter for tmux session (exact same as regular flow) + pidToAwaiter.set(tmuxResult.pid!, (completedSession) => { + clearTimeout(timeout); + logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook (tmux)`); + resolve({ + type: 'success', + sessionId: completedSession.happySessionId! + }); + }); + }); + } else { + logger.debug(`[DAEMON RUN] Failed to spawn in tmux: ${tmuxResult.error}, falling back to regular spawning`); + useTmux = false; } - }); + } + + // Regular process spawning (fallback or if tmux not available) + if (!useTmux) { + logger.debug(`[DAEMON RUN] Using regular process spawning`); + + // Construct arguments for the CLI + const args = [ + options.agent === 'claude' ? 'claude' : 'codex', + '--happy-starting-mode', 'remote', + '--started-by', 'daemon' + ]; + + // TODO: In future, sessionId could be used with --resume to continue existing sessions + // For now, we ignore it - each spawn creates a new session + const happyProcess = spawnHappyCLI(args, { + cwd: directory, + detached: true, // Sessions stay alive when daemon stops + stdio: ['ignore', 'pipe', 'pipe'], // Capture stdout/stderr for debugging + env: { + ...process.env, + ...extraEnv + } + }); - // Wait for webhook to populate session with happySessionId - logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${happyProcess.pid}`); + // Log output for debugging + if (process.env.DEBUG) { + happyProcess.stdout?.on('data', (data) => { + logger.debug(`[DAEMON RUN] Child stdout: ${data.toString()}`); + }); + happyProcess.stderr?.on('data', (data) => { + logger.debug(`[DAEMON RUN] Child stderr: ${data.toString()}`); + }); + } - return new Promise((resolve) => { - // Set timeout for webhook - const timeout = setTimeout(() => { - pidToAwaiter.delete(happyProcess.pid!); - logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${happyProcess.pid}`); - resolve({ + if (!happyProcess.pid) { + logger.debug('[DAEMON RUN] Failed to spawn process - no PID returned'); + return { type: 'error', - errorMessage: `Session webhook timeout for PID ${happyProcess.pid}` - }); - // 15 second timeout - I have seen timeouts on 10 seconds - // even though session was still created successfully in ~2 more seconds - }, 15_000); - - // Register awaiter - pidToAwaiter.set(happyProcess.pid!, (completedSession) => { - clearTimeout(timeout); - logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook`); - resolve({ - type: 'success', - sessionId: completedSession.happySessionId! + errorMessage: 'Failed to spawn Happy process - no PID returned' + }; + } + + logger.debug(`[DAEMON RUN] Spawned process with PID ${happyProcess.pid}`); + + const trackedSession: TrackedSession = { + startedBy: 'daemon', + pid: happyProcess.pid, + childProcess: happyProcess, + directoryCreated, + message: directoryCreated ? `The path '${directory}' did not exist. We created a new folder and spawned a new session there.` : undefined + }; + + pidToTrackedSession.set(happyProcess.pid, trackedSession); + + happyProcess.on('exit', (code, signal) => { + logger.debug(`[DAEMON RUN] Child PID ${happyProcess.pid} exited with code ${code}, signal ${signal}`); + if (happyProcess.pid) { + onChildExited(happyProcess.pid); + } + }); + + happyProcess.on('error', (error) => { + logger.debug(`[DAEMON RUN] Child process error:`, error); + if (happyProcess.pid) { + onChildExited(happyProcess.pid); + } + }); + + // Wait for webhook to populate session with happySessionId + logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${happyProcess.pid}`); + + return new Promise((resolve) => { + // Set timeout for webhook + const timeout = setTimeout(() => { + pidToAwaiter.delete(happyProcess.pid!); + logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${happyProcess.pid}`); + resolve({ + type: 'error', + errorMessage: `Session webhook timeout for PID ${happyProcess.pid}` + }); + // 15 second timeout - I have seen timeouts on 10 seconds + // even though session was still created successfully in ~2 more seconds + }, 15_000); + + // Register awaiter + pidToAwaiter.set(happyProcess.pid!, (completedSession) => { + clearTimeout(timeout); + logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook`); + resolve({ + type: 'success', + sessionId: completedSession.happySessionId! + }); }); }); - }); + } + + // This should never be reached, but TypeScript requires a return statement + return { + type: 'error', + errorMessage: 'Unexpected error in session spawning' + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.debug('[DAEMON RUN] Failed to spawn session:', error); diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 51db1adb..ed8f08aa 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -17,4 +17,6 @@ export interface TrackedSession { error?: string; directoryCreated?: boolean; message?: string; + /** tmux session identifier (format: session:window) */ + tmuxSessionId?: string; } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 72febfa8..21e70de1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -333,9 +333,9 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} `) // Run claude --help and display its output - // Use execFileSync with the current Node executable for cross-platform compatibility + // Use execFileSync directly with claude CLI for runtime-agnostic compatibility try { - const claudeHelp = execFileSync(process.execPath, [claudeCliPath, '--help'], { encoding: 'utf8' }) + const claudeHelp = execFileSync(claudeCliPath, ['--help'], { encoding: 'utf8' }) console.log(claudeHelp) } catch (e) { console.log(chalk.yellow('Could not retrieve claude help. Make sure claude is installed.')) diff --git a/src/modules/common/registerCommonHandlers.ts b/src/modules/common/registerCommonHandlers.ts index 756fecdb..d65d55ef 100644 --- a/src/modules/common/registerCommonHandlers.ts +++ b/src/modules/common/registerCommonHandlers.ts @@ -122,6 +122,19 @@ export interface SpawnSessionOptions { approvedNewDirectoryCreation?: boolean; agent?: 'claude' | 'codex'; token?: string; + environmentVariables?: { + // Anthropic Claude API configuration + ANTHROPIC_BASE_URL?: string; // Custom API endpoint (overrides default) + ANTHROPIC_AUTH_TOKEN?: string; // API authentication token + ANTHROPIC_MODEL?: string; // Model to use (e.g., claude-3-5-sonnet-20241022) + + // Tmux session management environment variables + // Based on tmux(1) manual and common tmux usage patterns + TMUX_SESSION_NAME?: string; // Name for tmux session (creates/attaches to named session) + TMUX_TMPDIR?: string; // Temporary directory for tmux server socket files + // Note: TMUX_TMPDIR is used by tmux to store socket files when default /tmp is not suitable + // Common use case: When /tmp has limited space or different permissions + }; } export type SpawnSessionResult = diff --git a/src/persistence.ts b/src/persistence.ts index d3f4e527..917a9425 100644 --- a/src/persistence.ts +++ b/src/persistence.ts @@ -11,18 +11,236 @@ import { constants } from 'node:fs' import { configuration } from '@/configuration' import * as z from 'zod'; import { encodeBase64 } from '@/api/encryption'; +import { logger } from '@/ui/logger'; + +// AI backend profile schema - MUST match happy app exactly +// Using same Zod schema as GUI for runtime validation consistency + +// Environment variable schemas for different AI providers (matching GUI exactly) +const AnthropicConfigSchema = z.object({ + baseUrl: z.string().url().optional(), + authToken: z.string().optional(), + model: z.string().optional(), +}); + +const OpenAIConfigSchema = z.object({ + apiKey: z.string().optional(), + baseUrl: z.string().url().optional(), + model: z.string().optional(), +}); + +const AzureOpenAIConfigSchema = z.object({ + apiKey: z.string().optional(), + endpoint: z.string().url().optional(), + apiVersion: z.string().optional(), + deploymentName: z.string().optional(), +}); + +const TogetherAIConfigSchema = z.object({ + apiKey: z.string().optional(), + model: z.string().optional(), +}); + +// Tmux configuration schema (matching GUI exactly) +const TmuxConfigSchema = z.object({ + sessionName: z.string().optional(), + tmpDir: z.string().optional(), + updateEnvironment: z.boolean().optional(), +}); + +// Environment variables schema with validation (matching GUI exactly) +const EnvironmentVariableSchema = z.object({ + name: z.string().regex(/^[A-Z_][A-Z0-9_]*$/, 'Invalid environment variable name'), + value: z.string(), +}); + +// Profile compatibility schema (matching GUI exactly) +const ProfileCompatibilitySchema = z.object({ + claude: z.boolean().default(true), + codex: z.boolean().default(true), +}); + +// AIBackendProfile schema - EXACT MATCH with GUI schema +export const AIBackendProfileSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(100), + description: z.string().max(500).optional(), + + // Agent-specific configurations + anthropicConfig: AnthropicConfigSchema.optional(), + openaiConfig: OpenAIConfigSchema.optional(), + azureOpenAIConfig: AzureOpenAIConfigSchema.optional(), + togetherAIConfig: TogetherAIConfigSchema.optional(), + + // Tmux configuration + tmuxConfig: TmuxConfigSchema.optional(), + + // Environment variables (validated) + environmentVariables: z.array(EnvironmentVariableSchema).default([]), + + // Default session type for this profile + defaultSessionType: z.enum(['simple', 'worktree']).optional(), + + // Default permission mode for this profile (supports both Claude and Codex modes) + defaultPermissionMode: z.enum([ + 'default', 'acceptEdits', 'bypassPermissions', 'plan', // Claude modes + 'read-only', 'safe-yolo', 'yolo' // Codex modes + ]).optional(), + + // Default model mode for this profile + defaultModelMode: z.string().optional(), + + // Compatibility metadata + compatibility: ProfileCompatibilitySchema.default({ claude: true, codex: true }), + + // Built-in profile indicator + isBuiltIn: z.boolean().default(false), + + // Metadata + createdAt: z.number().default(() => Date.now()), + updatedAt: z.number().default(() => Date.now()), + version: z.string().default('1.0.0'), +}); + +export type AIBackendProfile = z.infer; + +// Helper functions matching the happy app exactly +export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claude' | 'codex'): boolean { + return profile.compatibility[agent]; +} + +export function getProfileEnvironmentVariables(profile: AIBackendProfile): Record { + const envVars: Record = {}; + + // Add validated environment variables + profile.environmentVariables.forEach(envVar => { + envVars[envVar.name] = envVar.value; + }); + + // Add Anthropic config + if (profile.anthropicConfig) { + if (profile.anthropicConfig.baseUrl) envVars.ANTHROPIC_BASE_URL = profile.anthropicConfig.baseUrl; + if (profile.anthropicConfig.authToken) envVars.ANTHROPIC_AUTH_TOKEN = profile.anthropicConfig.authToken; + if (profile.anthropicConfig.model) envVars.ANTHROPIC_MODEL = profile.anthropicConfig.model; + } + + // Add OpenAI config + if (profile.openaiConfig) { + if (profile.openaiConfig.apiKey) envVars.OPENAI_API_KEY = profile.openaiConfig.apiKey; + if (profile.openaiConfig.baseUrl) envVars.OPENAI_BASE_URL = profile.openaiConfig.baseUrl; + if (profile.openaiConfig.model) envVars.OPENAI_MODEL = profile.openaiConfig.model; + } + + // Add Azure OpenAI config + if (profile.azureOpenAIConfig) { + if (profile.azureOpenAIConfig.apiKey) envVars.AZURE_OPENAI_API_KEY = profile.azureOpenAIConfig.apiKey; + if (profile.azureOpenAIConfig.endpoint) envVars.AZURE_OPENAI_ENDPOINT = profile.azureOpenAIConfig.endpoint; + if (profile.azureOpenAIConfig.apiVersion) envVars.AZURE_OPENAI_API_VERSION = profile.azureOpenAIConfig.apiVersion; + if (profile.azureOpenAIConfig.deploymentName) envVars.AZURE_OPENAI_DEPLOYMENT_NAME = profile.azureOpenAIConfig.deploymentName; + } + + // Add Together AI config + if (profile.togetherAIConfig) { + if (profile.togetherAIConfig.apiKey) envVars.TOGETHER_API_KEY = profile.togetherAIConfig.apiKey; + if (profile.togetherAIConfig.model) envVars.TOGETHER_MODEL = profile.togetherAIConfig.model; + } + + // Add Tmux config + if (profile.tmuxConfig) { + // Empty string means "use current/most recent session", so include it + if (profile.tmuxConfig.sessionName !== undefined) envVars.TMUX_SESSION_NAME = profile.tmuxConfig.sessionName; + if (profile.tmuxConfig.tmpDir) envVars.TMUX_TMPDIR = profile.tmuxConfig.tmpDir; + if (profile.tmuxConfig.updateEnvironment !== undefined) { + envVars.TMUX_UPDATE_ENVIRONMENT = profile.tmuxConfig.updateEnvironment.toString(); + } + } + + return envVars; +} + +// Profile validation function using Zod schema +export function validateProfile(profile: unknown): AIBackendProfile { + const result = AIBackendProfileSchema.safeParse(profile); + if (!result.success) { + throw new Error(`Invalid profile data: ${result.error.message}`); + } + return result.data; +} + + +// Profile versioning system +// Profile version: Semver string for individual profile data compatibility (e.g., "1.0.0") +// Used to version the AIBackendProfile schema itself (anthropicConfig, tmuxConfig, etc.) +export const CURRENT_PROFILE_VERSION = '1.0.0'; + +// Settings schema version: Integer for overall Settings structure compatibility +// Incremented when Settings structure changes (e.g., adding profiles array was v1→v2) +// Used for migration logic in readSettings() +export const SUPPORTED_SCHEMA_VERSION = 2; + +// Profile version validation +export function validateProfileVersion(profile: AIBackendProfile): boolean { + // Simple semver validation for now + const semverRegex = /^\d+\.\d+\.\d+$/; + return semverRegex.test(profile.version || ''); +} + +// Profile compatibility check for version upgrades +export function isProfileVersionCompatible(profileVersion: string, requiredVersion: string = CURRENT_PROFILE_VERSION): boolean { + // For now, all 1.x.x versions are compatible + const [major] = profileVersion.split('.'); + const [requiredMajor] = requiredVersion.split('.'); + return major === requiredMajor; +} interface Settings { + // Schema version for backwards compatibility + schemaVersion: number onboardingCompleted: boolean // This ID is used as the actual database ID on the server // All machine operations use this ID machineId?: string machineIdConfirmedByServer?: boolean daemonAutoStartWhenRunningHappy?: boolean + // Profile management settings (synced with happy app) + activeProfileId?: string + profiles: AIBackendProfile[] + // CLI-local environment variable cache (not synced) + localEnvironmentVariables: Record> // profileId -> env vars } const defaultSettings: Settings = { - onboardingCompleted: false + schemaVersion: SUPPORTED_SCHEMA_VERSION, + onboardingCompleted: false, + profiles: [], + localEnvironmentVariables: {} +} + +/** + * Migrate settings from old schema versions to current + * Always backwards compatible - preserves all data + */ +function migrateSettings(raw: any, fromVersion: number): any { + let migrated = { ...raw }; + + // Migration from v1 to v2 (added profile support) + if (fromVersion < 2) { + // Ensure profiles array exists + if (!migrated.profiles) { + migrated.profiles = []; + } + // Ensure localEnvironmentVariables exists + if (!migrated.localEnvironmentVariables) { + migrated.localEnvironmentVariables = {}; + } + // Update schema version + migrated.schemaVersion = 2; + } + + // Future migrations go here: + // if (fromVersion < 3) { ... } + + return migrated; } /** @@ -44,9 +262,47 @@ export async function readSettings(): Promise { } try { + // Read raw settings const content = await readFile(configuration.settingsFile, 'utf8') - return JSON.parse(content) - } catch { + const raw = JSON.parse(content) + + // Check schema version (default to 1 if missing) + const schemaVersion = raw.schemaVersion ?? 1; + + // Warn if schema version is newer than supported + if (schemaVersion > SUPPORTED_SCHEMA_VERSION) { + logger.warn( + `āš ļø Settings schema v${schemaVersion} > supported v${SUPPORTED_SCHEMA_VERSION}. ` + + 'Update happy-cli for full functionality.' + ); + } + + // Migrate if needed + const migrated = migrateSettings(raw, schemaVersion); + + // Validate and clean profiles gracefully (don't crash on invalid profiles) + if (migrated.profiles && Array.isArray(migrated.profiles)) { + const validProfiles: AIBackendProfile[] = []; + for (const profile of migrated.profiles) { + try { + const validated = AIBackendProfileSchema.parse(profile); + validProfiles.push(validated); + } catch (error: any) { + logger.warn( + `āš ļø Invalid profile "${profile?.name || profile?.id || 'unknown'}" - skipping. ` + + `Error: ${error.message}` + ); + // Continue processing other profiles + } + } + migrated.profiles = validProfiles; + } + + // Merge with defaults to ensure all required fields exist + return { ...defaultSettings, ...migrated }; + } catch (error: any) { + logger.warn(`Failed to read settings: ${error.message}`); + // Return defaults on any error return { ...defaultSettings } } } @@ -56,7 +312,13 @@ export async function writeSettings(settings: Settings): Promise { await mkdir(configuration.happyHomeDir, { recursive: true }) } - await writeFile(configuration.settingsFile, JSON.stringify(settings, null, 2)) + // Ensure schema version is set before writing + const settingsWithVersion = { + ...settings, + schemaVersion: settings.schemaVersion ?? SUPPORTED_SCHEMA_VERSION + }; + + await writeFile(configuration.settingsFile, JSON.stringify(settingsWithVersion, null, 2)) } /** @@ -320,3 +582,124 @@ export async function releaseDaemonLock(lockHandle: FileHandle): Promise { } catch { } } +// +// Profile Management +// + +/** + * Get all profiles from settings + */ +export async function getProfiles(): Promise { + const settings = await readSettings(); + return settings.profiles || []; +} + +/** + * Get a specific profile by ID + */ +export async function getProfile(profileId: string): Promise { + const settings = await readSettings(); + return settings.profiles.find(p => p.id === profileId) || null; +} + +/** + * Get the active profile + */ +export async function getActiveProfile(): Promise { + const settings = await readSettings(); + if (!settings.activeProfileId) return null; + return settings.profiles.find(p => p.id === settings.activeProfileId) || null; +} + +/** + * Set the active profile by ID + */ +export async function setActiveProfile(profileId: string): Promise { + await updateSettings(settings => ({ + ...settings, + activeProfileId: profileId + })); +} + +/** + * Update profiles (synced from happy app) with validation + */ +export async function updateProfiles(profiles: unknown[]): Promise { + // Validate all profiles using Zod schema + const validatedProfiles = profiles.map(profile => validateProfile(profile)); + + await updateSettings(settings => { + // Preserve active profile ID if it still exists + const activeProfileId = settings.activeProfileId; + const activeProfileStillExists = activeProfileId && validatedProfiles.some(p => p.id === activeProfileId); + + return { + ...settings, + profiles: validatedProfiles, + activeProfileId: activeProfileStillExists ? activeProfileId : undefined + }; + }); +} + +/** + * Get environment variables for a profile + * Combines profile custom env vars with CLI-local cached env vars + */ +export async function getEnvironmentVariables(profileId: string): Promise> { + const settings = await readSettings(); + const profile = settings.profiles.find(p => p.id === profileId); + if (!profile) return {}; + + // Start with profile's environment variables (new schema) + const envVars: Record = {}; + if (profile.environmentVariables) { + profile.environmentVariables.forEach(envVar => { + envVars[envVar.name] = envVar.value; + }); + } + + // Override with CLI-local cached environment variables + const localEnvVars = settings.localEnvironmentVariables[profileId] || {}; + Object.assign(envVars, localEnvVars); + + return envVars; +} + +/** + * Set environment variables for a profile in CLI-local cache + */ +export async function setEnvironmentVariables(profileId: string, envVars: Record): Promise { + await updateSettings(settings => ({ + ...settings, + localEnvironmentVariables: { + ...settings.localEnvironmentVariables, + [profileId]: envVars + } + })); +} + +/** + * Get a specific environment variable for a profile + * Checks CLI-local cache first, then profile environment variables + */ +export async function getEnvironmentVariable(profileId: string, key: string): Promise { + const settings = await readSettings(); + + // Check CLI-local cache first + const localEnvVars = settings.localEnvironmentVariables[profileId] || {}; + if (localEnvVars[key] !== undefined) { + return localEnvVars[key]; + } + + // Fall back to profile environment variables (new schema) + const profile = settings.profiles.find(p => p.id === profileId); + if (profile?.environmentVariables) { + const envVar = profile.environmentVariables.find(env => env.name === key); + if (envVar) { + return envVar.value; + } + } + + return undefined; +} + diff --git a/src/ui/auth.ts b/src/ui/auth.ts index 3ea33f21..964f8ace 100644 --- a/src/ui/auth.ts +++ b/src/ui/auth.ts @@ -30,15 +30,21 @@ export async function doAuth(): Promise { // Create a new authentication request try { - console.log(`[AUTH DEBUG] Sending auth request to: ${configuration.serverUrl}/v1/auth/request`); - console.log(`[AUTH DEBUG] Public key: ${encodeBase64(keypair.publicKey).substring(0, 20)}...`); + if (process.env.DEBUG) { + console.log(`[AUTH DEBUG] Sending auth request to: ${configuration.serverUrl}/v1/auth/request`); + console.log(`[AUTH DEBUG] Public key: ${encodeBase64(keypair.publicKey).substring(0, 20)}...`); + } await axios.post(`${configuration.serverUrl}/v1/auth/request`, { publicKey: encodeBase64(keypair.publicKey), supportsV2: true }); - console.log(`[AUTH DEBUG] Auth request sent successfully`); + if (process.env.DEBUG) { + console.log(`[AUTH DEBUG] Auth request sent successfully`); + } } catch (error) { - console.log(`[AUTH DEBUG] Failed to send auth request:`, error); + if (process.env.DEBUG) { + console.log(`[AUTH DEBUG] Failed to send auth request:`, error); + } console.log('Failed to create authentication request, please try again later.'); return null; } diff --git a/src/ui/logger.ts b/src/ui/logger.ts index ecf61936..ecf739b6 100644 --- a/src/ui/logger.ts +++ b/src/ui/logger.ts @@ -10,7 +10,8 @@ import { appendFileSync } from 'fs' import { configuration } from '@/configuration' import { existsSync, readdirSync, statSync } from 'node:fs' import { join, basename } from 'node:path' -import { readDaemonState } from '@/persistence' +// Note: readDaemonState is imported lazily inside listDaemonLogFiles() to avoid +// circular dependency: logger.ts ↔ persistence.ts /** * Consistent date/time formatting functions @@ -264,6 +265,8 @@ export async function listDaemonLogFiles(limit: number = 50): Promise { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('detects Node.js runtime correctly', () => { + // Test actual runtime detection + if (process.versions.node && !process.versions.bun && !process.versions.deno) { + const { getRuntime, isNode, isBun, isDeno } = require('../runtime.js'); + expect(getRuntime()).toBe('node'); + expect(isNode()).toBe(true); + expect(isBun()).toBe(false); + expect(isDeno()).toBe(false); + } + }); + + it('detects Bun runtime correctly', () => { + if (process.versions.bun) { + const { getRuntime, isNode, isBun, isDeno } = require('../runtime.js'); + expect(getRuntime()).toBe('bun'); + expect(isNode()).toBe(false); + expect(isBun()).toBe(true); + expect(isDeno()).toBe(false); + } + }); + + it('detects Deno runtime correctly', () => { + if (process.versions.deno) { + const { getRuntime, isNode, isBun, isDeno } = require('../runtime.js'); + expect(getRuntime()).toBe('deno'); + expect(isNode()).toBe(false); + expect(isBun()).toBe(false); + expect(isDeno()).toBe(true); + } + }); + + it('returns valid runtime type', () => { + const { getRuntime } = require('../runtime.js'); + const runtime = getRuntime(); + expect(['node', 'bun', 'deno', 'unknown']).toContain(runtime); + }); + + it('provides consistent predicate functions', () => { + const { getRuntime, isNode, isBun, isDeno } = require('../runtime.js'); + const runtime = getRuntime(); + + // Only one should be true + const trues = [isNode(), isBun(), isDeno()].filter(Boolean); + expect(trues.length).toBeLessThanOrEqual(1); + + // If runtime is not unknown, exactly one should be true + if (runtime !== 'unknown') { + expect(trues.length).toBe(1); + } + }); + + it('handles edge cases gracefully', () => { + const { getRuntime } = require('../runtime.js'); + + // Should not throw + expect(() => getRuntime()).not.toThrow(); + + // Should return string + const runtime = getRuntime(); + expect(typeof runtime).toBe('string'); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/runtimeIntegration.test.ts b/src/utils/__tests__/runtimeIntegration.test.ts new file mode 100644 index 00000000..23eb44a8 --- /dev/null +++ b/src/utils/__tests__/runtimeIntegration.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; + +describe('Runtime Integration Tests', () => { + it('runtime detection is consistent across imports', async () => { + const { getRuntime } = await import('../runtime.js'); + const runtime1 = getRuntime(); + + // Re-import to test caching + const { getRuntime: getRuntime2 } = await import('../runtime.js'); + const runtime2 = getRuntime2(); + + expect(runtime1).toBe(runtime2); + expect(['node', 'bun', 'deno', 'unknown']).toContain(runtime1); + }); + + it('runtime detection works in actual execution environment', async () => { + const { getRuntime, isNode, isBun, isDeno } = await import('../runtime.js'); + + const runtime = getRuntime(); + + if (process.versions.node && !process.versions.bun && !process.versions.deno) { + expect(runtime).toBe('node'); + expect(isNode()).toBe(true); + expect(isBun()).toBe(false); + expect(isDeno()).toBe(false); + } else if (process.versions.bun) { + expect(runtime).toBe('bun'); + expect(isNode()).toBe(false); + expect(isBun()).toBe(true); + expect(isDeno()).toBe(false); + } else if (process.versions.deno) { + expect(runtime).toBe('deno'); + expect(isNode()).toBe(false); + expect(isBun()).toBe(false); + expect(isDeno()).toBe(true); + } + }); + + it('runtime utilities can be imported correctly', async () => { + const runtimeModule = await import('../runtime.js'); + + // Check that all expected exports are available + expect(typeof runtimeModule.getRuntime).toBe('function'); + expect(typeof runtimeModule.isBun).toBe('function'); + expect(typeof runtimeModule.isNode).toBe('function'); + expect(typeof runtimeModule.isDeno).toBe('function'); + expect(typeof runtimeModule.getRuntime()).toBe('string'); + }); + + it('provides correct runtime type', async () => { + const { getRuntime } = await import('../runtime.js'); + const runtime = getRuntime(); + expect(['node', 'bun', 'deno', 'unknown']).toContain(runtime); + }); +}); \ No newline at end of file diff --git a/src/utils/expandEnvVars.test.ts b/src/utils/expandEnvVars.test.ts new file mode 100644 index 00000000..eea0844f --- /dev/null +++ b/src/utils/expandEnvVars.test.ts @@ -0,0 +1,264 @@ +/** + * Unit tests for environment variable expansion utility + */ +import { describe, expect, it } from 'vitest'; +import { expandEnvironmentVariables } from './expandEnvVars'; + +describe('expandEnvironmentVariables', () => { + it('should expand simple ${VAR} reference', () => { + const envVars = { + TARGET: '${SOURCE}' + }; + const sourceEnv = { + SOURCE: 'value123' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + TARGET: 'value123' + }); + }); + + it('should expand multiple ${VAR} references in same value', () => { + const envVars = { + PATH: '${BIN_DIR}:${LIB_DIR}' + }; + const sourceEnv = { + BIN_DIR: '/usr/bin', + LIB_DIR: '/usr/lib' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + PATH: '/usr/bin:/usr/lib' + }); + }); + + it('should expand ${VAR} in middle of string', () => { + const envVars = { + MESSAGE: 'Hello ${NAME}, welcome!' + }; + const sourceEnv = { + NAME: 'World' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + MESSAGE: 'Hello World, welcome!' + }); + }); + + it('should handle authentication token expansion pattern', () => { + const envVars = { + ANTHROPIC_AUTH_TOKEN: '${Z_AI_AUTH_TOKEN}' + }; + const sourceEnv = { + Z_AI_AUTH_TOKEN: 'sk-ant-real-key-12345' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'sk-ant-real-key-12345' + }); + }); + + it('should preserve values without ${VAR} references', () => { + const envVars = { + STATIC: 'plain-value', + NUMBER: '12345', + PATH: '/usr/bin:/usr/lib' + }; + const sourceEnv = { + UNUSED: 'ignored' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + STATIC: 'plain-value', + NUMBER: '12345', + PATH: '/usr/bin:/usr/lib' + }); + }); + + it('should leave unexpanded ${VAR} when variable not found in source', () => { + const envVars = { + TARGET: '${MISSING_VAR}' + }; + const sourceEnv = { + OTHER: 'value' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + TARGET: '${MISSING_VAR}' + }); + }); + + it('should handle partial expansion when some variables missing', () => { + const envVars = { + MIXED: '${EXISTS}:${MISSING}:${ALSO_EXISTS}' + }; + const sourceEnv = { + EXISTS: 'found1', + ALSO_EXISTS: 'found2' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + MIXED: 'found1:${MISSING}:found2' + }); + }); + + it('should handle empty string values in source environment', () => { + const envVars = { + TARGET: '${EMPTY_VAR}' + }; + const sourceEnv = { + EMPTY_VAR: '' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + TARGET: '' + }); + }); + + it('should handle multiple variables with same source', () => { + const envVars = { + VAR1: '${SHARED}', + VAR2: 'prefix-${SHARED}', + VAR3: '${SHARED}-suffix' + }; + const sourceEnv = { + SHARED: 'common-value' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + VAR1: 'common-value', + VAR2: 'prefix-common-value', + VAR3: 'common-value-suffix' + }); + }); + + it('should not modify original envVars object', () => { + const envVars = { + TARGET: '${SOURCE}' + }; + const sourceEnv = { + SOURCE: 'value' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + + // Original should be unchanged + expect(envVars).toEqual({ + TARGET: '${SOURCE}' + }); + + // Result should be expanded + expect(result).toEqual({ + TARGET: 'value' + }); + }); + + it('should use process.env as default source when not provided', () => { + // Save original + const originalPath = process.env.PATH; + + const envVars = { + MY_PATH: '${PATH}' + }; + + const result = expandEnvironmentVariables(envVars); + expect(result.MY_PATH).toBe(originalPath); + }); + + it('should handle nested braces correctly', () => { + const envVars = { + COMPLEX: '${VAR1}/${VAR2}/literal-${}' + }; + const sourceEnv = { + VAR1: 'part1', + VAR2: 'part2' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + COMPLEX: 'part1/part2/literal-${}' + }); + }); + + it('should handle variables with underscores and numbers', () => { + const envVars = { + TARGET: '${MY_VAR_123}' + }; + const sourceEnv = { + MY_VAR_123: 'value-with-numbers' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + TARGET: 'value-with-numbers' + }); + }); + + it('should handle real-world profile environment variables scenario', () => { + const profileEnvVars = { + ANTHROPIC_AUTH_TOKEN: '${Z_AI_AUTH_TOKEN}', + ANTHROPIC_BASE_URL: 'https://api.anthropic.com', + OPENAI_API_KEY: '${Z_OPENAI_KEY}', + CUSTOM_PATH: '/custom:${HOME}/bin' + }; + const daemonEnv = { + Z_AI_AUTH_TOKEN: 'sk-ant-12345', + Z_OPENAI_KEY: 'sk-proj-67890', + HOME: '/Users/test' + }; + + const result = expandEnvironmentVariables(profileEnvVars, daemonEnv); + expect(result).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'sk-ant-12345', + ANTHROPIC_BASE_URL: 'https://api.anthropic.com', + OPENAI_API_KEY: 'sk-proj-67890', + CUSTOM_PATH: '/custom:/Users/test/bin' + }); + }); + + it('should handle undefined source environment gracefully', () => { + const envVars = { + TARGET: '${MISSING}' + }; + + // undefined source should fall back to process.env + const result = expandEnvironmentVariables(envVars, undefined as any); + + // Should return unexpanded since variable likely not in process.env + expect(result.TARGET).toContain('${'); + }); + + it('should handle empty objects', () => { + const result = expandEnvironmentVariables({}, {}); + expect(result).toEqual({}); + }); + + it('should not expand malformed ${} references', () => { + const envVars = { + BAD1: '${', + BAD2: '${}', + BAD3: 'text-${', + GOOD: '${VALID}' + }; + const sourceEnv = { + VALID: 'expanded' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + BAD1: '${', + BAD2: '${}', + BAD3: 'text-${', + GOOD: 'expanded' + }); + }); +}); diff --git a/src/utils/expandEnvVars.ts b/src/utils/expandEnvVars.ts new file mode 100644 index 00000000..f4e08f77 --- /dev/null +++ b/src/utils/expandEnvVars.ts @@ -0,0 +1,96 @@ +import { logger } from '@/ui/logger'; + +/** + * Expands ${VAR} references in environment variable values. + * + * CONTEXT: + * Profiles can use ${VAR} syntax to reference daemon's environment: + * Example: { ANTHROPIC_AUTH_TOKEN: "${Z_AI_AUTH_TOKEN}" } + * + * When daemon spawns sessions: + * - Tmux mode: Shell automatically expands ${VAR} + * - Non-tmux mode: Node.js spawn does NOT expand ${VAR} + * + * This utility ensures ${VAR} expansion works in both modes. + * + * @param envVars - Environment variables that may contain ${VAR} references + * @param sourceEnv - Source environment (usually process.env) to resolve references from + * @returns New object with all ${VAR} references expanded to actual values + * + * @example + * ```typescript + * const daemon_env = { Z_AI_AUTH_TOKEN: "sk-real-key" }; + * const profile_vars = { ANTHROPIC_AUTH_TOKEN: "${Z_AI_AUTH_TOKEN}" }; + * + * const expanded = expandEnvironmentVariables(profile_vars, daemon_env); + * // Result: { ANTHROPIC_AUTH_TOKEN: "sk-real-key" } + * ``` + */ +export function expandEnvironmentVariables( + envVars: Record, + sourceEnv: NodeJS.ProcessEnv = process.env +): Record { + const expanded: Record = {}; + const undefinedVars: string[] = []; + + for (const [key, value] of Object.entries(envVars)) { + // Replace all ${VAR} and ${VAR:-default} references with actual values from sourceEnv + const expandedValue = value.replace(/\$\{([^}]+)\}/g, (match, expr) => { + // Support bash parameter expansion: ${VAR:-default} + // Example: ${Z_AI_BASE_URL:-https://api.z.ai/api/anthropic} + const colonDashIndex = expr.indexOf(':-'); + let varName: string; + let defaultValue: string | undefined; + + if (colonDashIndex !== -1) { + // Split ${VAR:-default} into varName and defaultValue + varName = expr.substring(0, colonDashIndex); + defaultValue = expr.substring(colonDashIndex + 2); + } else { + // Simple ${VAR} reference + varName = expr; + } + + const resolvedValue = sourceEnv[varName]; + if (resolvedValue !== undefined) { + // Variable found in source environment - use its value + // Log for debugging (mask secret-looking values) + const isSensitive = varName.toLowerCase().includes('token') || + varName.toLowerCase().includes('key') || + varName.toLowerCase().includes('secret'); + const displayValue = isSensitive + ? (resolvedValue ? `<${resolvedValue.length} chars>` : '') + : resolvedValue; + logger.debug(`[EXPAND ENV] Expanded ${varName} from daemon env: ${displayValue}`); + + // Warn if empty string (common mistake) + if (resolvedValue === '') { + logger.warn(`[EXPAND ENV] WARNING: ${varName} is set but EMPTY in daemon environment`); + } + + return resolvedValue; + } else if (defaultValue !== undefined) { + // Variable not found but default value provided - use default + logger.debug(`[EXPAND ENV] Using default value for ${varName}: ${defaultValue}`); + return defaultValue; + } else { + // Variable not found and no default - keep placeholder and warn + undefinedVars.push(varName); + return match; + } + }); + + expanded[key] = expandedValue; + } + + // Log warning if any variables couldn't be resolved + if (undefinedVars.length > 0) { + logger.warn(`[EXPAND ENV] Undefined variables referenced in profile environment: ${undefinedVars.join(', ')}`); + logger.warn(`[EXPAND ENV] Session may fail to authenticate. Set these in daemon environment before launching:`); + undefinedVars.forEach(varName => { + logger.warn(`[EXPAND ENV] ${varName}=`); + }); + } + + return expanded; +} diff --git a/src/utils/runtime.ts b/src/utils/runtime.ts new file mode 100644 index 00000000..018bf85e --- /dev/null +++ b/src/utils/runtime.ts @@ -0,0 +1,53 @@ +/** + * Runtime utilities - minimal, focused, testable + * Single responsibility: detect current JavaScript runtime + */ + +// Type safety with explicit union +export type Runtime = 'node' | 'bun' | 'deno' | 'unknown'; + +// Cache result after first detection (performance optimization) +let cachedRuntime: Runtime | null = null; + +/** + * Detect current runtime with fallback chain + * Most reliable detection first, falling back to less reliable methods + */ +export function getRuntime(): Runtime { + if (cachedRuntime) return cachedRuntime; + + // Method 1: Global runtime objects (most reliable) + if (typeof (globalThis as any).Bun !== 'undefined') { + cachedRuntime = 'bun'; + return cachedRuntime; + } + + if (typeof (globalThis as any).Deno !== 'undefined') { + cachedRuntime = 'deno'; + return cachedRuntime; + } + + // Method 2: Process versions (fallback) + if (process?.versions?.bun) { + cachedRuntime = 'bun'; + return cachedRuntime; + } + + if (process?.versions?.deno) { + cachedRuntime = 'deno'; + return cachedRuntime; + } + + if (process?.versions?.node) { + cachedRuntime = 'node'; + return cachedRuntime; + } + + cachedRuntime = 'unknown'; + return cachedRuntime; +} + +// Convenience predicates - single responsibility each +export const isBun = (): boolean => getRuntime() === 'bun'; +export const isNode = (): boolean => getRuntime() === 'node'; +export const isDeno = (): boolean => getRuntime() === 'deno'; \ No newline at end of file diff --git a/src/utils/spawnHappyCLI.ts b/src/utils/spawnHappyCLI.ts index 1ed7d2d7..560633ff 100644 --- a/src/utils/spawnHappyCLI.ts +++ b/src/utils/spawnHappyCLI.ts @@ -54,6 +54,7 @@ import { join } from 'node:path'; import { projectPath } from '@/projectPath'; import { logger } from '@/ui/logger'; import { existsSync } from 'node:fs'; +import { isBun } from './runtime'; /** * Spawn the Happy CLI with the given arguments in a cross-platform way. @@ -99,5 +100,6 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child throw new Error(errorMessage); } - return spawn('node', nodeArgs, options); + const runtime = isBun() ? 'bun' : 'node'; + return spawn(runtime, nodeArgs, options); } diff --git a/src/utils/tmux.test.ts b/src/utils/tmux.test.ts new file mode 100644 index 00000000..c5628e98 --- /dev/null +++ b/src/utils/tmux.test.ts @@ -0,0 +1,456 @@ +/** + * Unit tests for tmux utilities + * + * NOTE: These are pure unit tests that test parsing and validation logic. + * They do NOT require tmux to be installed on the system. + * All tests mock environment variables and test string parsing only. + */ +import { describe, expect, it } from 'vitest'; +import { + parseTmuxSessionIdentifier, + formatTmuxSessionIdentifier, + validateTmuxSessionIdentifier, + buildTmuxSessionIdentifier, + TmuxSessionIdentifierError, + TmuxUtilities, + type TmuxSessionIdentifier, +} from './tmux'; + +describe('parseTmuxSessionIdentifier', () => { + it('should parse session-only identifier', () => { + const result = parseTmuxSessionIdentifier('my-session'); + expect(result).toEqual({ + session: 'my-session' + }); + }); + + it('should parse session:window identifier', () => { + const result = parseTmuxSessionIdentifier('my-session:window-1'); + expect(result).toEqual({ + session: 'my-session', + window: 'window-1' + }); + }); + + it('should parse session:window.pane identifier', () => { + const result = parseTmuxSessionIdentifier('my-session:window-1.2'); + expect(result).toEqual({ + session: 'my-session', + window: 'window-1', + pane: '2' + }); + }); + + it('should handle session names with dots, hyphens, and underscores', () => { + const result = parseTmuxSessionIdentifier('my.test_session-1'); + expect(result).toEqual({ + session: 'my.test_session-1' + }); + }); + + it('should handle window names with hyphens and underscores', () => { + const result = parseTmuxSessionIdentifier('session:my_test-window-1'); + expect(result).toEqual({ + session: 'session', + window: 'my_test-window-1' + }); + }); + + it('should throw on empty string', () => { + expect(() => parseTmuxSessionIdentifier('')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('')).toThrow('Session identifier must be a non-empty string'); + }); + + it('should throw on null/undefined', () => { + expect(() => parseTmuxSessionIdentifier(null as any)).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier(undefined as any)).toThrow(TmuxSessionIdentifierError); + }); + + it('should throw on invalid session name characters', () => { + expect(() => parseTmuxSessionIdentifier('invalid session')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('invalid session')).toThrow('Only alphanumeric characters, dots, hyphens, and underscores are allowed'); + }); + + it('should throw on special characters in session name', () => { + expect(() => parseTmuxSessionIdentifier('session@name')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('session#name')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('session$name')).toThrow(TmuxSessionIdentifierError); + }); + + it('should throw on invalid window name characters', () => { + expect(() => parseTmuxSessionIdentifier('session:invalid window')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('session:invalid window')).toThrow('Only alphanumeric characters, dots, hyphens, and underscores are allowed'); + }); + + it('should throw on non-numeric pane identifier', () => { + expect(() => parseTmuxSessionIdentifier('session:window.abc')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('session:window.abc')).toThrow('Only numeric values are allowed'); + }); + + it('should throw on pane identifier with special characters', () => { + expect(() => parseTmuxSessionIdentifier('session:window.1a')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('session:window.-1')).toThrow(TmuxSessionIdentifierError); + }); + + it('should trim whitespace from components', () => { + const result = parseTmuxSessionIdentifier('session : window . 2'); + expect(result).toEqual({ + session: 'session', + window: 'window', + pane: '2' + }); + }); +}); + +describe('formatTmuxSessionIdentifier', () => { + it('should format session-only identifier', () => { + const identifier: TmuxSessionIdentifier = { session: 'my-session' }; + expect(formatTmuxSessionIdentifier(identifier)).toBe('my-session'); + }); + + it('should format session:window identifier', () => { + const identifier: TmuxSessionIdentifier = { + session: 'my-session', + window: 'window-1' + }; + expect(formatTmuxSessionIdentifier(identifier)).toBe('my-session:window-1'); + }); + + it('should format session:window.pane identifier', () => { + const identifier: TmuxSessionIdentifier = { + session: 'my-session', + window: 'window-1', + pane: '2' + }; + expect(formatTmuxSessionIdentifier(identifier)).toBe('my-session:window-1.2'); + }); + + it('should ignore pane when window is not provided', () => { + const identifier: TmuxSessionIdentifier = { + session: 'my-session', + pane: '2' + }; + expect(formatTmuxSessionIdentifier(identifier)).toBe('my-session'); + }); + + it('should throw when session is missing', () => { + const identifier: TmuxSessionIdentifier = { session: '' }; + expect(() => formatTmuxSessionIdentifier(identifier)).toThrow(TmuxSessionIdentifierError); + expect(() => formatTmuxSessionIdentifier(identifier)).toThrow('Session identifier must have a session name'); + }); + + it('should handle complex valid names', () => { + const identifier: TmuxSessionIdentifier = { + session: 'my.test_session-1', + window: 'my_test-window-2', + pane: '3' + }; + expect(formatTmuxSessionIdentifier(identifier)).toBe('my.test_session-1:my_test-window-2.3'); + }); +}); + +describe('validateTmuxSessionIdentifier', () => { + it('should return valid:true for valid session-only identifier', () => { + const result = validateTmuxSessionIdentifier('my-session'); + expect(result).toEqual({ valid: true }); + }); + + it('should return valid:true for valid session:window identifier', () => { + const result = validateTmuxSessionIdentifier('my-session:window-1'); + expect(result).toEqual({ valid: true }); + }); + + it('should return valid:true for valid session:window.pane identifier', () => { + const result = validateTmuxSessionIdentifier('my-session:window-1.2'); + expect(result).toEqual({ valid: true }); + }); + + it('should return valid:false for empty string', () => { + const result = validateTmuxSessionIdentifier(''); + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('should return valid:false for invalid session characters', () => { + const result = validateTmuxSessionIdentifier('invalid session'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Only alphanumeric characters'); + }); + + it('should return valid:false for invalid window characters', () => { + const result = validateTmuxSessionIdentifier('session:invalid window'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Only alphanumeric characters'); + }); + + it('should return valid:false for invalid pane identifier', () => { + const result = validateTmuxSessionIdentifier('session:window.abc'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Only numeric values are allowed'); + }); + + it('should handle complex valid identifiers', () => { + const result = validateTmuxSessionIdentifier('my.test_session-1:my_test-window-2.3'); + expect(result).toEqual({ valid: true }); + }); + + it('should not throw exceptions', () => { + expect(() => validateTmuxSessionIdentifier('')).not.toThrow(); + expect(() => validateTmuxSessionIdentifier('invalid session')).not.toThrow(); + expect(() => validateTmuxSessionIdentifier(null as any)).not.toThrow(); + }); +}); + +describe('buildTmuxSessionIdentifier', () => { + it('should build session-only identifier', () => { + const result = buildTmuxSessionIdentifier({ session: 'my-session' }); + expect(result).toEqual({ + success: true, + identifier: 'my-session' + }); + }); + + it('should build session:window identifier', () => { + const result = buildTmuxSessionIdentifier({ + session: 'my-session', + window: 'window-1' + }); + expect(result).toEqual({ + success: true, + identifier: 'my-session:window-1' + }); + }); + + it('should build session:window.pane identifier', () => { + const result = buildTmuxSessionIdentifier({ + session: 'my-session', + window: 'window-1', + pane: '2' + }); + expect(result).toEqual({ + success: true, + identifier: 'my-session:window-1.2' + }); + }); + + it('should return error for empty session name', () => { + const result = buildTmuxSessionIdentifier({ session: '' }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid session name'); + }); + + it('should return error for invalid session characters', () => { + const result = buildTmuxSessionIdentifier({ session: 'invalid session' }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid session name'); + }); + + it('should return error for invalid window characters', () => { + const result = buildTmuxSessionIdentifier({ + session: 'session', + window: 'invalid window' + }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid window name'); + }); + + it('should return error for invalid pane identifier', () => { + const result = buildTmuxSessionIdentifier({ + session: 'session', + window: 'window', + pane: 'abc' + }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid pane identifier'); + }); + + it('should handle complex valid inputs', () => { + const result = buildTmuxSessionIdentifier({ + session: 'my.test_session-1', + window: 'my_test-window-2', + pane: '3' + }); + expect(result).toEqual({ + success: true, + identifier: 'my.test_session-1:my_test-window-2.3' + }); + }); + + it('should not throw exceptions for invalid inputs', () => { + expect(() => buildTmuxSessionIdentifier({ session: '' })).not.toThrow(); + expect(() => buildTmuxSessionIdentifier({ session: 'invalid session' })).not.toThrow(); + expect(() => buildTmuxSessionIdentifier({ session: null as any })).not.toThrow(); + }); +}); + +describe('TmuxUtilities.detectTmuxEnvironment', () => { + const originalTmuxEnv = process.env.TMUX; + + // Helper to set and restore environment + const withTmuxEnv = (value: string | undefined, fn: () => void) => { + process.env.TMUX = value; + try { + fn(); + } finally { + if (originalTmuxEnv !== undefined) { + process.env.TMUX = originalTmuxEnv; + } else { + delete process.env.TMUX; + } + } + }; + + it('should return null when TMUX env is not set', () => { + withTmuxEnv(undefined, () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toBeNull(); + }); + }); + + it('should parse valid TMUX environment variable', () => { + withTmuxEnv('/tmp/tmux-1000/default,4219,0', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toEqual({ + session: '4219', + window: '0', + pane: '0', + socket_path: '/tmp/tmux-1000/default' + }); + }); + }); + + it('should parse TMUX env with session.window format', () => { + withTmuxEnv('/tmp/tmux-1000/default,mysession.mywindow,2', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toEqual({ + session: 'mysession', + window: 'mywindow', + pane: '2', + socket_path: '/tmp/tmux-1000/default' + }); + }); + }); + + it('should handle TMUX env without session.window format', () => { + withTmuxEnv('/tmp/tmux-1000/default,session123,1', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toEqual({ + session: 'session123', + window: '0', + pane: '1', + socket_path: '/tmp/tmux-1000/default' + }); + }); + }); + + it('should handle complex socket paths correctly', () => { + // CRITICAL: Test that path parsing works with the fixed array indexing + withTmuxEnv('/tmp/tmux-1000/my-socket,5678,3', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toEqual({ + session: '5678', + window: '0', + pane: '3', + socket_path: '/tmp/tmux-1000/my-socket' + }); + }); + }); + + it('should handle socket path with multiple slashes', () => { + // Test the array indexing fix - ensure we get the last component correctly + withTmuxEnv('/var/run/tmux/1000/default,session.window,0', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toEqual({ + session: 'session', + window: 'window', + pane: '0', + socket_path: '/var/run/tmux/1000/default' + }); + }); + }); + + it('should return null for malformed TMUX env (too few parts)', () => { + withTmuxEnv('/tmp/tmux-1000/default,4219', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toBeNull(); + }); + }); + + it('should return null for malformed TMUX env (empty string)', () => { + withTmuxEnv('', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toBeNull(); + }); + }); + + it('should handle TMUX env with extra parts (more than 3 comma-separated values)', () => { + withTmuxEnv('/tmp/tmux-1000/default,4219,0,extra', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + // Should still parse the first 3 parts correctly + expect(result).toEqual({ + session: '4219', + window: '0', + pane: '0', + socket_path: '/tmp/tmux-1000/default' + }); + }); + }); + + it('should handle edge case with dots in session identifier', () => { + withTmuxEnv('/tmp/tmux-1000/default,my.session.name.5,2', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + // Split on dot, so my.session becomes session=my, window=session + expect(result).toEqual({ + session: 'my', + window: 'session', + pane: '2', + socket_path: '/tmp/tmux-1000/default' + }); + }); + }); +}); + +describe('Round-trip consistency', () => { + it('should parse and format consistently for session-only', () => { + const original = 'my-session'; + const parsed = parseTmuxSessionIdentifier(original); + const formatted = formatTmuxSessionIdentifier(parsed); + expect(formatted).toBe(original); + }); + + it('should parse and format consistently for session:window', () => { + const original = 'my-session:window-1'; + const parsed = parseTmuxSessionIdentifier(original); + const formatted = formatTmuxSessionIdentifier(parsed); + expect(formatted).toBe(original); + }); + + it('should parse and format consistently for session:window.pane', () => { + const original = 'my-session:window-1.2'; + const parsed = parseTmuxSessionIdentifier(original); + const formatted = formatTmuxSessionIdentifier(parsed); + expect(formatted).toBe(original); + }); + + it('should build and parse consistently', () => { + const params = { + session: 'my-session', + window: 'window-1', + pane: '2' + }; + const built = buildTmuxSessionIdentifier(params); + expect(built.success).toBe(true); + const parsed = parseTmuxSessionIdentifier(built.identifier!); + expect(parsed).toEqual(params); + }); +}); diff --git a/src/utils/tmux.ts b/src/utils/tmux.ts new file mode 100644 index 00000000..f0958358 --- /dev/null +++ b/src/utils/tmux.ts @@ -0,0 +1,1052 @@ +/** + * TypeScript tmux utilities adapted from Python reference + * + * Copyright 2025 Andrew Hundt + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Centralized tmux utilities with control sequence support and session management + * Ensures consistent tmux handling across happy-cli with proper session naming + */ + +import { spawn, SpawnOptions } from 'child_process'; +import { promisify } from 'util'; +import { logger } from '@/ui/logger'; + +export enum TmuxControlState { + /** Normal text processing mode */ + NORMAL = "normal", + /** Escape to tmux control mode */ + ESCAPE = "escape", + /** Literal character mode */ + LITERAL = "literal" +} + +/** Union type of valid tmux control sequences for better type safety */ +export type TmuxControlSequence = + | 'C-m' | 'C-c' | 'C-l' | 'C-u' | 'C-w' | 'C-a' | 'C-b' | 'C-d' | 'C-e' | 'C-f' + | 'C-g' | 'C-h' | 'C-i' | 'C-j' | 'C-k' | 'C-n' | 'C-o' | 'C-p' | 'C-q' | 'C-r' + | 'C-s' | 'C-t' | 'C-v' | 'C-x' | 'C-y' | 'C-z' | 'C-\\' | 'C-]' | 'C-[' | 'C-]'; + +/** Union type of valid tmux window operations for better type safety */ +export type TmuxWindowOperation = + // Navigation and window management + | 'new-window' | 'new' | 'nw' + | 'select-window' | 'sw' | 'window' | 'w' + | 'next-window' | 'n' | 'prev-window' | 'p' | 'pw' + // Pane management + | 'split-window' | 'split' | 'sp' | 'vsplit' | 'vsp' + | 'select-pane' | 'pane' + | 'next-pane' | 'np' | 'prev-pane' | 'pp' + // Session management + | 'new-session' | 'ns' | 'new-sess' + | 'attach-session' | 'attach' | 'as' + | 'detach-client' | 'detach' | 'dc' + // Layout and display + | 'select-layout' | 'layout' | 'sl' + | 'clock-mode' | 'clock' + | 'copy-mode' | 'copy' + | 'search-forward' | 'search-backward' + // Misc operations + | 'list-windows' | 'lw' | 'list-sessions' | 'ls' | 'list-panes' | 'lp' + | 'rename-window' | 'rename' | 'kill-window' | 'kw' + | 'kill-pane' | 'kp' | 'kill-session' | 'ks' + // Display and info + | 'display-message' | 'display' | 'dm' + | 'show-options' | 'show' | 'so' + // Control and scripting + | 'send-keys' | 'send' | 'sk' + | 'capture-pane' | 'capture' | 'cp' + | 'pipe-pane' | 'pipe' + // Buffer operations + | 'list-buffers' | 'lb' | 'save-buffer' | 'sb' + | 'delete-buffer' | 'db' + // Advanced operations + | 'resize-pane' | 'resize' | 'rp' + | 'swap-pane' | 'swap' + | 'join-pane' | 'join' | 'break-pane' | 'break'; + +export interface TmuxEnvironment { + session: string; + window: string; + pane: string; + socket_path?: string; +} + +export interface TmuxCommandResult { + returncode: number; + stdout: string; + stderr: string; + command: string[]; +} + +export interface TmuxSessionInfo { + target_session: string; + session: string; + window: string; + pane: string; + socket_path?: string; + tmux_active: boolean; + current_session?: string; + env_session?: string; + env_window?: string; + env_pane?: string; + available_sessions: string[]; +} + +// Strongly typed tmux session identifier with validation +export interface TmuxSessionIdentifier { + session: string; + window?: string; + pane?: string; +} + +/** Validation error for tmux session identifiers */ +export class TmuxSessionIdentifierError extends Error { + constructor(message: string) { + super(message); + this.name = 'TmuxSessionIdentifierError'; + } +} + +// Helper to parse tmux session identifier from string with validation +export function parseTmuxSessionIdentifier(identifier: string): TmuxSessionIdentifier { + if (!identifier || typeof identifier !== 'string') { + throw new TmuxSessionIdentifierError('Session identifier must be a non-empty string'); + } + + // Format: session:window or session:window.pane or just session + const parts = identifier.split(':'); + if (parts.length === 0 || !parts[0]) { + throw new TmuxSessionIdentifierError('Invalid session identifier: missing session name'); + } + + const result: TmuxSessionIdentifier = { + session: parts[0].trim() + }; + + // Validate session name (tmux has restrictions on session names) + if (!/^[a-zA-Z0-9._-]+$/.test(result.session)) { + throw new TmuxSessionIdentifierError(`Invalid session name: "${result.session}". Only alphanumeric characters, dots, hyphens, and underscores are allowed.`); + } + + if (parts.length > 1) { + const windowAndPane = parts[1].split('.'); + result.window = windowAndPane[0]?.trim(); + + if (result.window && !/^[a-zA-Z0-9._-]+$/.test(result.window)) { + throw new TmuxSessionIdentifierError(`Invalid window name: "${result.window}". Only alphanumeric characters, dots, hyphens, and underscores are allowed.`); + } + + if (windowAndPane.length > 1) { + result.pane = windowAndPane[1]?.trim(); + if (result.pane && !/^[0-9]+$/.test(result.pane)) { + throw new TmuxSessionIdentifierError(`Invalid pane identifier: "${result.pane}". Only numeric values are allowed.`); + } + } + } + + return result; +} + +// Helper to format tmux session identifier to string +export function formatTmuxSessionIdentifier(identifier: TmuxSessionIdentifier): string { + if (!identifier.session) { + throw new TmuxSessionIdentifierError('Session identifier must have a session name'); + } + + let result = identifier.session; + if (identifier.window) { + result += `:${identifier.window}`; + if (identifier.pane) { + result += `.${identifier.pane}`; + } + } + return result; +} + +// Helper to extract session and window from tmux output with improved validation +export function extractSessionAndWindow(tmuxOutput: string): { session: string; window: string } | null { + if (!tmuxOutput || typeof tmuxOutput !== 'string') { + return null; + } + + // Look for session:window patterns in tmux output + const lines = tmuxOutput.split('\n'); + + for (const line of lines) { + const match = line.match(/^([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+)(?:\.([0-9]+))?/); + if (match) { + return { + session: match[1], + window: match[2] + }; + } + } + + return null; +} + +export interface TmuxSpawnOptions extends Omit { + /** Target tmux session name */ + sessionName?: string; + /** Custom tmux socket path */ + socketPath?: string; + /** Create new window in existing session */ + createWindow?: boolean; + /** Window name for new windows */ + windowName?: string; + // Note: env is intentionally excluded from this interface. + // It's passed as a separate parameter to spawnInTmux() for clarity + // and efficiency - only variables that differ from the tmux server + // environment need to be passed via -e flags. +} + +/** + * Complete WIN_OPS dispatch dictionary for tmux operations + * Maps operation names to tmux commands with proper typing + */ +const WIN_OPS: Record = { + // Navigation and window management + 'new-window': 'new-window', + 'new': 'new-window', + 'nw': 'new-window', + + 'select-window': 'select-window -t', + 'sw': 'select-window -t', + 'window': 'select-window -t', + 'w': 'select-window -t', + + 'next-window': 'next-window', + 'n': 'next-window', + 'prev-window': 'previous-window', + 'p': 'previous-window', + 'pw': 'previous-window', + + // Pane management + 'split-window': 'split-window', + 'split': 'split-window', + 'sp': 'split-window', + 'vsplit': 'split-window -h', + 'vsp': 'split-window -h', + + 'select-pane': 'select-pane -t', + 'pane': 'select-pane -t', + + 'next-pane': 'select-pane -t :.+', + 'np': 'select-pane -t :.+', + 'prev-pane': 'select-pane -t :.-', + 'pp': 'select-pane -t :.-', + + // Session management + 'new-session': 'new-session', + 'ns': 'new-session', + 'new-sess': 'new-session', + + 'attach-session': 'attach-session -t', + 'attach': 'attach-session -t', + 'as': 'attach-session -t', + + 'detach-client': 'detach-client', + 'detach': 'detach-client', + 'dc': 'detach-client', + + // Layout and display + 'select-layout': 'select-layout', + 'layout': 'select-layout', + 'sl': 'select-layout', + + 'clock-mode': 'clock-mode', + 'clock': 'clock-mode', + + // Copy mode + 'copy-mode': 'copy-mode', + 'copy': 'copy-mode', + + // Search and navigation in copy mode + 'search-forward': 'search-forward', + 'search-backward': 'search-backward', + + // Misc operations + 'list-windows': 'list-windows', + 'lw': 'list-windows', + 'list-sessions': 'list-sessions', + 'ls': 'list-sessions', + 'list-panes': 'list-panes', + 'lp': 'list-panes', + + 'rename-window': 'rename-window', + 'rename': 'rename-window', + + 'kill-window': 'kill-window', + 'kw': 'kill-window', + 'kill-pane': 'kill-pane', + 'kp': 'kill-pane', + 'kill-session': 'kill-session', + 'ks': 'kill-session', + + // Display and info + 'display-message': 'display-message', + 'display': 'display-message', + 'dm': 'display-message', + + 'show-options': 'show-options', + 'show': 'show-options', + 'so': 'show-options', + + // Control and scripting + 'send-keys': 'send-keys', + 'send': 'send-keys', + 'sk': 'send-keys', + + 'capture-pane': 'capture-pane', + 'capture': 'capture-pane', + 'cp': 'capture-pane', + + 'pipe-pane': 'pipe-pane', + 'pipe': 'pipe-pane', + + // Buffer operations + 'list-buffers': 'list-buffers', + 'lb': 'list-buffers', + 'save-buffer': 'save-buffer', + 'sb': 'save-buffer', + 'delete-buffer': 'delete-buffer', + 'db': 'delete-buffer', + + // Advanced operations + 'resize-pane': 'resize-pane', + 'resize': 'resize-pane', + 'rp': 'resize-pane', + + 'swap-pane': 'swap-pane', + 'swap': 'swap-pane', + + 'join-pane': 'join-pane', + 'join': 'join-pane', + 'break-pane': 'break-pane', + 'break': 'break-pane', +}; + +// Commands that support session targeting +const COMMANDS_SUPPORTING_TARGET = new Set([ + 'send-keys', 'capture-pane', 'new-window', 'kill-window', + 'select-window', 'split-window', 'select-pane', 'kill-pane', + 'select-layout', 'display-message', 'attach-session', 'detach-client', + 'new-session', 'kill-session', 'list-windows', 'list-panes' +]); + +// Control sequences that must be separate arguments with proper typing +const CONTROL_SEQUENCES: Set = new Set([ + 'C-m', 'C-c', 'C-l', 'C-u', 'C-w', 'C-a', 'C-b', 'C-d', 'C-e', 'C-f', + 'C-g', 'C-h', 'C-i', 'C-j', 'C-k', 'C-n', 'C-o', 'C-p', 'C-q', 'C-r', + 'C-s', 'C-t', 'C-v', 'C-x', 'C-y', 'C-z', 'C-\\', 'C-]', 'C-[', 'C-]' +]); + +export class TmuxUtilities { + /** Default session name to prevent interference */ + public static readonly DEFAULT_SESSION_NAME = "happy"; + + private controlState: TmuxControlState = TmuxControlState.NORMAL; + public readonly sessionName: string; + + constructor(sessionName?: string) { + this.sessionName = sessionName || TmuxUtilities.DEFAULT_SESSION_NAME; + } + + /** + * Detect tmux environment from TMUX environment variable + */ + detectTmuxEnvironment(): TmuxEnvironment | null { + const tmuxEnv = process.env.TMUX; + if (!tmuxEnv) { + return null; + } + + // Parse TMUX environment: /tmp/tmux-1000/default,4219,0 + try { + const parts = tmuxEnv.split(','); + if (parts.length >= 3) { + const socketPath = parts[0]; + // Extract last component from path (JavaScript doesn't support negative array indexing) + const pathParts = parts[1].split('/'); + const sessionAndWindow = pathParts[pathParts.length - 1] || parts[1]; + const pane = parts[2]; + + // Extract session name from session.window format + let session: string; + let window: string; + if (sessionAndWindow.includes('.')) { + const parts = sessionAndWindow.split('.', 2); + session = parts[0]; + window = parts[1] || "0"; + } else { + session = sessionAndWindow; + window = "0"; + } + + return { + session, + window, + pane, + socket_path: socketPath + }; + } + } catch (error) { + logger.debug('[TMUX] Failed to parse TMUX environment variable:', error); + } + + return null; + } + + /** + * Execute tmux command with proper session targeting and socket handling + */ + async executeTmuxCommand( + cmd: string[], + session?: string, + window?: string, + pane?: string, + socketPath?: string + ): Promise { + const targetSession = session || this.sessionName; + + // Build command array + let baseCmd = ['tmux']; + + // Add socket specification if provided + if (socketPath) { + baseCmd = ['tmux', '-S', socketPath]; + } + + // Handle send-keys with proper target specification + if (cmd.length > 0 && cmd[0] === 'send-keys') { + const fullCmd = [...baseCmd, cmd[0]]; + + // Add target specification immediately after send-keys + let target = targetSession; + if (window) target += `:${window}`; + if (pane) target += `.${pane}`; + fullCmd.push('-t', target); + + // Add keys and control sequences + fullCmd.push(...cmd.slice(1)); + + return this.executeCommand(fullCmd); + } else { + // Non-send-keys commands + const fullCmd = [...baseCmd, ...cmd]; + + // Add target specification for commands that support it + if (cmd.length > 0 && COMMANDS_SUPPORTING_TARGET.has(cmd[0])) { + let target = targetSession; + if (window) target += `:${window}`; + if (pane) target += `.${pane}`; + fullCmd.push('-t', target); + } + + return this.executeCommand(fullCmd); + } + } + + /** + * Execute command with subprocess and return result + */ + private async executeCommand(cmd: string[]): Promise { + try { + const result = await this.runCommand(cmd); + return { + returncode: result.exitCode, + stdout: result.stdout || '', + stderr: result.stderr || '', + command: cmd + }; + } catch (error) { + logger.debug('[TMUX] Command execution failed:', error); + return null; + } + } + + /** + * Run command using Node.js child_process.spawn + */ + private runCommand(args: string[], options: SpawnOptions = {}): Promise<{ exitCode: number; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(args[0], args.slice(1), { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + shell: false, + ...options + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + resolve({ + exitCode: code || 0, + stdout, + stderr + }); + }); + + child.on('error', (error) => { + reject(error); + }); + }); + } + + /** + * Parse control sequences in text (^ for escape, ^^ for literal ^) + */ + parseControlSequences(text: string): [string, TmuxControlState] { + const result: string[] = []; + let i = 0; + let localState = this.controlState; + + while (i < text.length) { + const char = text[i]; + + if (localState === TmuxControlState.NORMAL) { + if (char === '^') { + if (i + 1 < text.length && text[i + 1] === '^') { + // Literal ^ + result.push('^'); + i += 2; + } else { + // Escape to normal tmux + localState = TmuxControlState.ESCAPE; + i += 1; + } + } else { + result.push(char); + i += 1; + } + } else if (localState === TmuxControlState.ESCAPE) { + // In escape mode - pass through to tmux directly + result.push(char); + i += 1; + localState = TmuxControlState.NORMAL; + } else { + result.push(char); + i += 1; + } + } + + this.controlState = localState; + return [result.join(''), localState]; + } + + /** + * Execute window operation using WIN_OPS dispatch with type safety + */ + async executeWinOp( + operation: TmuxWindowOperation, + args: string[] = [], + session?: string, + window?: string, + pane?: string + ): Promise { + const tmuxCmd = WIN_OPS[operation]; + if (!tmuxCmd) { + logger.debug(`[TMUX] Unknown operation: ${operation}`); + return false; + } + + const cmdParts = tmuxCmd.split(' '); + cmdParts.push(...args); + + const result = await this.executeTmuxCommand(cmdParts, session, window, pane); + return result !== null && result.returncode === 0; + } + + /** + * Ensure session exists, create if needed + */ + async ensureSessionExists(sessionName?: string): Promise { + const targetSession = sessionName || this.sessionName; + + // Check if session exists + const result = await this.executeTmuxCommand(['has-session', '-t', targetSession]); + if (result && result.returncode === 0) { + return true; + } + + // Create session if it doesn't exist + const createResult = await this.executeTmuxCommand(['new-session', '-d', '-s', targetSession]); + return createResult !== null && createResult.returncode === 0; + } + + /** + * Capture current input from tmux pane + */ + async captureCurrentInput( + session?: string, + window?: string, + pane?: string + ): Promise { + const result = await this.executeTmuxCommand(['capture-pane', '-p'], session, window, pane); + if (result && result.returncode === 0) { + const lines = result.stdout.trim().split('\n'); + return lines[lines.length - 1] || ''; + } + return ''; + } + + /** + * Check if user is actively typing + */ + async isUserTyping( + checkInterval: number = 500, + maxChecks: number = 3, + session?: string, + window?: string, + pane?: string + ): Promise { + const initialInput = await this.captureCurrentInput(session, window, pane); + + for (let i = 0; i < maxChecks - 1; i++) { + await new Promise(resolve => setTimeout(resolve, checkInterval)); + const currentInput = await this.captureCurrentInput(session, window, pane); + if (currentInput !== initialInput) { + return true; + } + } + + return false; + } + + /** + * Send keys to tmux pane with proper control sequence handling and type safety + */ + async sendKeys( + keys: string | TmuxControlSequence, + session?: string, + window?: string, + pane?: string + ): Promise { + // Validate input + if (!keys || typeof keys !== 'string') { + logger.debug('[TMUX] Invalid keys provided to sendKeys'); + return false; + } + + // Handle control sequences that must be separate arguments + if (CONTROL_SEQUENCES.has(keys as TmuxControlSequence)) { + const result = await this.executeTmuxCommand(['send-keys', keys], session, window, pane); + return result !== null && result.returncode === 0; + } else { + // Regular text + const result = await this.executeTmuxCommand(['send-keys', keys], session, window, pane); + return result !== null && result.returncode === 0; + } + } + + /** + * Send multiple keys to tmux pane with proper control sequence handling + */ + async sendMultipleKeys( + keys: Array, + session?: string, + window?: string, + pane?: string + ): Promise { + if (!Array.isArray(keys) || keys.length === 0) { + logger.debug('[TMUX] Invalid keys array provided to sendMultipleKeys'); + return false; + } + + for (const key of keys) { + const success = await this.sendKeys(key, session, window, pane); + if (!success) { + return false; + } + } + + return true; + } + + /** + * Get comprehensive session information + */ + async getSessionInfo(sessionName?: string): Promise { + const targetSession = sessionName || this.sessionName; + const envInfo = this.detectTmuxEnvironment(); + + const info: TmuxSessionInfo = { + target_session: targetSession, + session: targetSession, + window: "unknown", + pane: "unknown", + socket_path: undefined, + tmux_active: envInfo !== null, + current_session: envInfo?.session, + available_sessions: [] + }; + + // Update with environment info if it matches our target session + if (envInfo && envInfo.session === targetSession) { + info.window = envInfo.window; + info.pane = envInfo.pane; + info.socket_path = envInfo.socket_path; + } else if (envInfo) { + // Add environment info as separate fields + info.env_session = envInfo.session; + info.env_window = envInfo.window; + info.env_pane = envInfo.pane; + } + + // Get available sessions + const result = await this.executeTmuxCommand(['list-sessions']); + if (result && result.returncode === 0) { + info.available_sessions = result.stdout + .trim() + .split('\n') + .filter(line => line.trim()) + .map(line => line.split(':')[0]); + } + + return info; + } + + /** + * Spawn process in tmux session with environment variables. + * + * IMPORTANT: Unlike Node.js spawn(), env is a separate parameter. + * This is intentional because: + * - Tmux windows inherit environment from the tmux server + * - Only NEW or DIFFERENT variables need to be set via -e flag + * - Passing all of process.env would create 50+ unnecessary -e flags + * + * @param args - Command and arguments to execute (as array, will be joined) + * @param options - Spawn options (tmux-specific, excludes env) + * @param env - Environment variables to set in window (only pass what's different!) + * @returns Result with success status and session identifier + */ + async spawnInTmux( + args: string[], + options: TmuxSpawnOptions = {}, + env?: Record + ): Promise<{ success: boolean; sessionId?: string; pid?: number; error?: string }> { + try { + // Check if tmux is available + const tmuxCheck = await this.executeTmuxCommand(['list-sessions']); + if (!tmuxCheck) { + throw new Error('tmux not available'); + } + + // Handle session name resolution + // - undefined: Use first existing session or create "happy" + // - empty string: Use first existing session or create "happy" + // - specific name: Use that session (create if doesn't exist) + let sessionName = options.sessionName !== undefined && options.sessionName !== '' + ? options.sessionName + : null; + + // If no specific session name, try to use first existing session + if (!sessionName) { + const listResult = await this.executeTmuxCommand(['list-sessions', '-F', '#{session_name}']); + if (listResult && listResult.returncode === 0 && listResult.stdout.trim()) { + // Use first session from list + const firstSession = listResult.stdout.trim().split('\n')[0]; + sessionName = firstSession; + logger.debug(`[TMUX] Using first existing session: ${sessionName}`); + } else { + // No sessions exist, create "happy" + sessionName = 'happy'; + logger.debug(`[TMUX] No existing sessions, using default: ${sessionName}`); + } + } + + const windowName = options.windowName || `happy-${Date.now()}`; + + // Ensure session exists + await this.ensureSessionExists(sessionName); + + // Build command to execute in the new window + const fullCommand = args.join(' '); + + // Create new window in session with command and environment variables + // IMPORTANT: Don't manually add -t here - executeTmuxCommand handles it via parameters + const createWindowArgs = ['new-window', '-n', windowName]; + + // Add working directory if specified + if (options.cwd) { + const cwdPath = typeof options.cwd === 'string' ? options.cwd : options.cwd.pathname; + createWindowArgs.push('-c', cwdPath); + } + + // Add environment variables using -e flag (sets them in the window's environment) + // Note: tmux windows inherit environment from tmux server, but we need to ensure + // the daemon's environment variables (especially expanded auth variables) are available + if (env && Object.keys(env).length > 0) { + for (const [key, value] of Object.entries(env)) { + // Skip undefined/null values with warning + if (value === undefined || value === null) { + logger.warn(`[TMUX] Skipping undefined/null environment variable: ${key}`); + continue; + } + + // Validate variable name (tmux accepts standard env var names) + if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) { + logger.warn(`[TMUX] Skipping invalid environment variable name: ${key}`); + continue; + } + + // Escape value for shell safety + // Must escape: backslashes, double quotes, dollar signs, backticks + const escapedValue = value + .replace(/\\/g, '\\\\') // Backslash first! + .replace(/"/g, '\\"') // Double quotes + .replace(/\$/g, '\\$') // Dollar signs + .replace(/`/g, '\\`'); // Backticks + + createWindowArgs.push('-e', `${key}="${escapedValue}"`); + } + logger.debug(`[TMUX] Setting ${Object.keys(env).length} environment variables in tmux window`); + } + + // Add the command to run in the window (runs immediately when window is created) + createWindowArgs.push(fullCommand); + + // Add -P flag to print the pane PID immediately + createWindowArgs.push('-P'); + createWindowArgs.push('-F', '#{pane_pid}'); + + // Create window with command and get PID immediately + const createResult = await this.executeTmuxCommand(createWindowArgs, sessionName); + + if (!createResult || createResult.returncode !== 0) { + throw new Error(`Failed to create tmux window: ${createResult?.stderr}`); + } + + // Extract the PID from the output + const panePid = parseInt(createResult.stdout.trim()); + if (isNaN(panePid)) { + throw new Error(`Failed to extract PID from tmux output: ${createResult.stdout}`); + } + + logger.debug(`[TMUX] Spawned command in tmux session ${sessionName}, window ${windowName}, PID ${panePid}`); + + // Return tmux session info and PID + const sessionIdentifier: TmuxSessionIdentifier = { + session: sessionName, + window: windowName + }; + + return { + success: true, + sessionId: formatTmuxSessionIdentifier(sessionIdentifier), + pid: panePid + }; + } catch (error) { + logger.debug('[TMUX] Failed to spawn in tmux:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + } + + /** + * Get session info for a given session identifier string + */ + async getSessionInfoFromString(sessionIdentifier: string): Promise { + try { + const parsed = parseTmuxSessionIdentifier(sessionIdentifier); + const info = await this.getSessionInfo(parsed.session); + return info; + } catch (error) { + if (error instanceof TmuxSessionIdentifierError) { + logger.debug(`[TMUX] Invalid session identifier: ${error.message}`); + } else { + logger.debug('[TMUX] Error getting session info:', error); + } + return null; + } + } + + /** + * Kill a tmux window safely with proper error handling + */ + async killWindow(sessionIdentifier: string): Promise { + try { + const parsed = parseTmuxSessionIdentifier(sessionIdentifier); + if (!parsed.window) { + throw new TmuxSessionIdentifierError(`Window identifier required: ${sessionIdentifier}`); + } + + const result = await this.executeWinOp('kill-window', [parsed.window], parsed.session); + return result; + } catch (error) { + if (error instanceof TmuxSessionIdentifierError) { + logger.debug(`[TMUX] Invalid window identifier: ${error.message}`); + } else { + logger.debug('[TMUX] Error killing window:', error); + } + return false; + } + } + + /** + * List windows in a session + */ + async listWindows(sessionName?: string): Promise { + const targetSession = sessionName || this.sessionName; + const result = await this.executeTmuxCommand(['list-windows', '-t', targetSession]); + + if (!result || result.returncode !== 0) { + return []; + } + + // Parse window names from tmux output + const windows: string[] = []; + const lines = result.stdout.trim().split('\n'); + + for (const line of lines) { + const match = line.match(/^\d+:\s+(\w+)/); + if (match) { + windows.push(match[1]); + } + } + + return windows; + } +} + +// Global instance for consistent usage +let _tmuxUtils: TmuxUtilities | null = null; + +export function getTmuxUtilities(sessionName?: string): TmuxUtilities { + if (!_tmuxUtils || (sessionName && sessionName !== _tmuxUtils.sessionName)) { + _tmuxUtils = new TmuxUtilities(sessionName); + } + return _tmuxUtils; +} + +export async function isTmuxAvailable(): Promise { + try { + const utils = new TmuxUtilities(); + const result = await utils.executeTmuxCommand(['list-sessions']); + return result !== null; + } catch { + return false; + } +} + +/** + * Create a new tmux session with proper typing and validation + */ +export async function createTmuxSession( + sessionName: string, + options?: { + windowName?: string; + detached?: boolean; + attach?: boolean; + } +): Promise<{ success: boolean; sessionIdentifier?: string; error?: string }> { + try { + if (!sessionName || !/^[a-zA-Z0-9._-]+$/.test(sessionName)) { + throw new TmuxSessionIdentifierError(`Invalid session name: "${sessionName}"`); + } + + const utils = new TmuxUtilities(sessionName); + const windowName = options?.windowName || 'main'; + + const cmd = ['new-session']; + if (options?.detached !== false) { + cmd.push('-d'); + } + cmd.push('-s', sessionName); + cmd.push('-n', windowName); + + const result = await utils.executeTmuxCommand(cmd); + if (result && result.returncode === 0) { + const sessionIdentifier: TmuxSessionIdentifier = { + session: sessionName, + window: windowName + }; + return { + success: true, + sessionIdentifier: formatTmuxSessionIdentifier(sessionIdentifier) + }; + } else { + return { + success: false, + error: result?.stderr || 'Failed to create tmux session' + }; + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } +} + +/** + * Validate a tmux session identifier without throwing + */ +export function validateTmuxSessionIdentifier(identifier: string): { valid: boolean; error?: string } { + try { + parseTmuxSessionIdentifier(identifier); + return { valid: true }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : 'Unknown validation error' + }; + } +} + +/** + * Build a tmux session identifier with validation + */ +export function buildTmuxSessionIdentifier(params: { + session: string; + window?: string; + pane?: string; +}): { success: boolean; identifier?: string; error?: string } { + try { + if (!params.session || !/^[a-zA-Z0-9._-]+$/.test(params.session)) { + throw new TmuxSessionIdentifierError(`Invalid session name: "${params.session}"`); + } + + if (params.window && !/^[a-zA-Z0-9._-]+$/.test(params.window)) { + throw new TmuxSessionIdentifierError(`Invalid window name: "${params.window}"`); + } + + if (params.pane && !/^[0-9]+$/.test(params.pane)) { + throw new TmuxSessionIdentifierError(`Invalid pane identifier: "${params.pane}"`); + } + + const identifier: TmuxSessionIdentifier = params; + return { + success: true, + identifier: formatTmuxSessionIdentifier(identifier) + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} \ No newline at end of file