diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6cd6e5..e8db4cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.21.x' + go-version: '1.24.x' cache: true - name: Install dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3339e6a..e74cf47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.21.x' + go-version: '1.24.x' cache: false - name: Install dependencies diff --git a/README.md b/README.md index fabcc44..49eaaf2 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,19 @@ # Codes CLI -A powerful CLI tool for managing multiple Claude Code configurations with ease. Switch between different Claude API endpoints, manage authentication tokens, and streamline your AI-powered development workflow. +A powerful CLI tool for managing multiple Claude Code configurations with ease. Includes an interactive TUI, MCP server integration, and multi-session terminal management. ## Features -- **Multi-Configuration Management**: Manage multiple Claude API configurations (official Anthropic, proxies, or alternative providers) -- **Easy Switching**: Quickly switch between configurations with an interactive selector -- **Smart Directory Launch**: Remember last working directory and support project aliases for quick access -- **Environment Import**: Automatically detect and import existing Claude configurations from environment variables -- **Automatic Installation**: Automatically installs and updates Claude CLI when needed -- **API Validation**: Tests API connectivity before saving configurations and provides testing tools +- **Interactive TUI**: Full terminal UI with Projects, Profiles, and Settings tabs +- **Multi-Profile Management**: Manage multiple API profiles (official Anthropic, proxies, or alternative providers) +- **Session Manager**: Launch Claude in separate terminal windows with multi-instance support per project +- **MCP Server**: Expose project and profile management as MCP tools for Claude Code integration +- **Smart Directory Launch**: Remember last working directory and support project aliases +- **Environment Import**: Auto-detect and import existing Claude configurations from environment variables +- **API Validation**: Test API connectivity before saving and provide testing tools - **Cross-Platform**: Support for Linux, macOS, and Windows (amd64 & arm64) -- **Zero Configuration**: Works out of the box with sensible defaults -- **Configuration Management**: Global configuration settings for CLI behavior -- **Permission Control**: Manage --dangerously-skip-permissions flag for Claude CLI -- **Flexible Startup Behavior**: Control where Claude starts when no arguments provided +- **JSON Output**: Machine-readable output via `--json` flag for scripting +- **Shell Completion**: Auto-completion for bash, zsh, fish, and powershell ## Quick Install @@ -36,8 +35,6 @@ irm https://raw.githubusercontent.com/ourines/codes/main/install.ps1 | iex Download the latest release for your platform from the [releases page](https://github.com/ourines/codes/releases). -#### Linux / macOS - ```bash # Download and extract (example for linux-amd64) curl -L https://github.com/ourines/codes/releases/latest/download/codes-linux-amd64 -o codes @@ -47,143 +44,100 @@ chmod +x codes ./codes init ``` -#### Windows - -```powershell -# Download the binary for your architecture -# Then run the init command -.\codes.exe init -``` - ### Build from Source Requirements: -- Go 1.21 or later +- Go 1.24 or later - npm (required for Claude CLI installation) ```bash -# Clone the repository git clone https://github.com/ourines/codes.git cd codes - -# Build the binary make build - -# Install to system PATH and set up shell completion ./codes init ``` -## Quick Start +## Usage -### 1. Initialize Your Environment +### Interactive TUI -```bash -codes init -``` - -This installs the binary to your system PATH, sets up shell completion, and verifies that everything is configured correctly. - -### 2. Add Your First Configuration - -```bash -codes add -``` - -You'll be prompted to enter: -- Configuration name (e.g., "official", "proxy") -- `ANTHROPIC_BASE_URL` (e.g., `https://api.anthropic.com`) -- `ANTHROPIC_AUTH_TOKEN` (your API key) - -The tool will automatically test the API connection before saving. - -### 3. Run Claude with Your Configuration +Running `codes` without arguments in a terminal launches the interactive TUI: ```bash codes ``` -This will launch Claude CLI with the selected configuration's environment variables. +The TUI has three tabs (cycle with `Tab`): -### 4. Switch Between Configurations +| Tab | Description | Key Bindings | +|-----|-------------|--------------| +| **Projects** | Manage project aliases, launch sessions | `a` add, `d` delete, `Enter` open session, `→` sessions panel, `k` kill, `t` cycle terminal | +| **Profiles** | Manage API profiles, switch default | `a` add profile, `Enter` set as default | +| **Settings** | Configure terminal, behavior, permissions | `↑↓` navigate, `Enter`/`Space` cycle value | -```bash -codes select -``` +**Settings tab options:** -An interactive menu will appear showing all your configurations. Select one to switch and launch Claude. +| Setting | Values | Description | +|---------|--------|-------------| +| Terminal | terminal / iterm / warp | Terminal emulator for sessions | +| Default Behavior | current / last / home | Where Claude starts without arguments | +| Skip Permissions | off / on | Global `--dangerously-skip-permissions` | +| Config File | *(read-only)* | Shows config file path | -## Commands +### Session Manager -### `codes init` +From the Projects tab, press `Enter` on a project to launch Claude in a new terminal window. Each project supports multiple concurrent sessions. -Initialize and configure the codes CLI. This command performs three main tasks: +- Sessions open in the configured terminal (Terminal.app, iTerm2, or Warp on macOS) +- Press `→` to view running sessions for the selected project +- Press `k` to kill sessions +- Session status auto-refreshes every 3 seconds -1. **Installs the binary** to your system PATH (`/usr/local/bin` on Linux/macOS, `~/go/bin` on Windows) -2. **Sets up shell completion** automatically for your current shell -3. **Runs environment health checks**, including: - - Verifies Claude CLI installation - - Detects existing Claude configuration in environment variables - - Offers to import existing configuration if found - - Checks configuration file existence and validity - - Tests API connectivity for the default configuration +### MCP Server -```bash -codes init -``` - -**Example output:** -```text -✓ Binary installed to /usr/local/bin/codes -✓ Shell completion configured for zsh -✓ Claude CLI is installed -✓ Found existing configuration in environment variables -✓ Configuration file exists -✓ Found 3 configuration(s) -✓ Default configuration is working -``` - -### `codes` (no arguments) - -Runs Claude CLI with the currently selected configuration in the last used directory. If Claude CLI is not installed, it will be automatically installed. The tool remembers your last working directory for convenience. +Start codes as an MCP server for integration with Claude Code: ```bash -codes +codes serve ``` -You can also specify a directory or project alias: +**Available MCP tools:** -```bash -codes /path/to/project -codes my-project # if you've added a project alias -``` +| Tool | Description | +|------|-------------| +| `list_projects` | List all project aliases with git status | +| `add_project` | Add a new project alias | +| `remove_project` | Remove a project alias | +| `list_profiles` | List all API profiles with status | +| `switch_profile` | Switch the default API profile | +| `get_project_info` | Get detailed project info (git branch, dirty status) | -### `codes completion [shell]` - -Generate shell completion scripts. While `codes init` automatically sets up completion, you can also generate scripts manually. +**Claude Code MCP config** (`~/.claude/claude_desktop_config.json`): -```bash -codes completion [bash|zsh|fish|powershell] +```json +{ + "mcpServers": { + "codes": { + "command": "codes", + "args": ["serve"] + } + } +} ``` -**Manual setup (for reference):** - -```bash -# Zsh (add to ~/.zshrc) -source <(codes completion zsh) +## Commands -# Bash (add to ~/.bashrc) -source <(codes completion bash) +### `codes init` -# Fish -codes completion fish | source +Initialize the CLI: install binary, set up shell completion, run health checks. -# PowerShell -codes completion powershell | Out-String | Invoke-Expression +```bash +codes init ``` ### `codes add` -Interactively add a new Claude API configuration. +Interactively add a new API profile. ```bash codes add @@ -191,166 +145,109 @@ codes add ### `codes select` -Display all configurations and interactively select one to use. +Display all profiles and interactively select one. ```bash codes select ``` -### `codes update` +### `codes test [profile-name]` -Update or install a specific version of Claude CLI. +Test API connectivity for all profiles or a specific one. ```bash -codes update +codes test # test all +codes test work # test specific profile ``` -Lists the latest 20 available versions from npm and allows you to: -- Select a version by number (1-20) -- Enter a specific version number (e.g., `1.2.3`) -- Type `latest` to install the newest version - -### `codes test` +### `codes start [path-or-project]` -Test API configuration connectivity and validate configurations. +Start Claude in a specific directory or project alias. ```bash -# Test all configured API endpoints -codes test - -# Test a specific configuration -codes test my-config +codes start . # current directory +codes start /path/to/dir # specific path +codes start my-project # project alias ``` -**Features**: -- Tests API connectivity using actual Claude API endpoint (`/v1/messages`) -- Shows model being used for each test -- Updates configuration status (active/inactive) based on test results -- Displays test results with clear success/failure indicators -- Tests all environment variables including authentication tokens and base URLs -- Validates that required fields are present and properly formatted - -### `codes config` +### `codes project` -Manage global CLI configuration settings. +Manage project aliases. ```bash -# Show all configuration values -codes config get - -# Show specific configuration value -codes config get - -# Set configuration value -codes config set +codes project add my-app /path/to/my-app +codes project list +codes project remove my-app ``` -**Current Configuration Keys**: -- `defaultBehavior` - Controls where Claude starts when no arguments are provided +### `codes terminal` -**Environment Variables Managed**: -- While the config command itself doesn't directly manage environment variables, it works with configurations that contain environment variables for: - - `ANTHROPIC_BASE_URL` - API endpoint URL - - `ANTHROPIC_AUTH_TOKEN` - Authentication token - - `ANTHROPIC_MODEL` - Default model - - `ANTHROPIC_DEFAULT_HAIKU_MODEL` - Haiku model override - - `ANTHROPIC_DEFAULT_OPUS_MODEL` - Opus model override - - `ANTHROPIC_DEFAULT_SONNET_MODEL` - Sonnet model override - - And other Claude CLI recognized environment variables +Configure which terminal emulator to use for sessions. + +```bash +codes terminal get # show current +codes terminal set iterm # set to iTerm2 +codes terminal list # list options +``` ### `codes defaultbehavior` -Manage the default startup behavior when running `codes` without arguments. +Control where Claude starts when no arguments are provided. ```bash -# Show current default behavior codes defaultbehavior get - -# Set default behavior -codes defaultbehavior set - -# Reset to default +codes defaultbehavior set last # current | last | home codes defaultbehavior reset ``` -**Available Behaviors**: -- `current` - Start Claude in the current working directory (default) -- `last` - Start Claude in the last used directory (remembered from previous runs) -- `home` - Start Claude in the user's home directory - -**Important Details**: -- Behavior is saved in config.json under `DefaultBehavior` field -- When `codes` is run without arguments, it uses this setting to determine the working directory -- The `codes start` command still works with project aliases and specific paths - ### `codes skippermissions` -Manage the global `--dangerously-skip-permissions` flag setting for Claude CLI. +Manage the global `--dangerously-skip-permissions` flag. ```bash -# Show current global skipPermissions setting codes skippermissions get - -# Set global skipPermissions -codes skippermissions set - -# Reset to default +codes skippermissions set true codes skippermissions reset ``` -**How It Works**: -- Global setting applies to all configurations that don't have their own `skipPermissions` setting -- When `true`, Claude runs with `--dangerously-skip-permissions` flag -- When `false` (default), Claude runs without the flag -- Individual configurations can override this global setting during `codes add` or by editing the config file - -**Important Details**: -- Setting is stored in config.json under `SkipPermissions` field -- Individual configurations can have their own `skipPermissions` setting that takes precedence -- This controls whether Claude bypasses certain security checks for file system access - -### `codes start [path-or-project]` +### `codes config` -Start Claude Code in a specific directory or using a project alias. Without arguments, it uses the last working directory. +Manage global CLI configuration. ```bash -# Start in current directory (and remember it) -codes start . - -# Start in specific path -codes start /path/to/project - -# Start using project alias -codes start my-project +codes config get +codes config set defaultBehavior last ``` -### `codes project add ` +### `codes update` -Add a project alias for quick access to frequently used directories. +Update Claude CLI to a specific version. ```bash -codes project add my-app /path/to/my-app +codes update ``` -### `codes project list` +### `codes serve` -List all configured project aliases. +Start MCP server over stdio. ```bash -codes project list +codes serve ``` -### `codes project remove ` +### `codes completion [shell]` -Remove a project alias. +Generate shell completion scripts. ```bash -codes project remove my-app +source <(codes completion zsh) # Zsh +source <(codes completion bash) # Bash +codes completion fish | source # Fish ``` ### `codes version` -Display the current version of codes CLI. +Display the current version. ```bash codes version @@ -358,33 +255,33 @@ codes version ## Configuration -### Configuration File Location +### File Location -The tool searches for `config.json` in the following order: +The tool searches for `config.json` in order: 1. Current working directory: `./config.json` 2. User home directory: `~/.codes/config.json` -### Configuration Format - -Create a `config.json` file with the following structure: +### Format ```json { - "configs": [ + "profiles": [ { - "name": "official", + "name": "work", "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_AUTH_TOKEN": "sk-ant-xxxxx", - "ANTHROPIC_MODEL": "claude-3-5-sonnet-20241022" + "ANTHROPIC_MODEL": "claude-sonnet-4-20250514" }, - "skipPermissions": false + "skipPermissions": false, + "status": "active" } ], - "default": "official", + "default": "work", "skipPermissions": false, "defaultBehavior": "current", + "terminal": "terminal", "projects": { "my-project": "/path/to/project" }, @@ -392,33 +289,43 @@ Create a `config.json` file with the following structure: } ``` -### Configuration Fields - -#### Global Settings -- `default`: The configuration name to use by default -- `skipPermissions`: Global setting for `--dangerously-skip-permissions` flag (applies to all configs unless overridden) -- `defaultBehavior`: Controls where Claude starts when no arguments provided ("current", "last", "home") -- `projects`: Object mapping project names to their directory paths -- `lastWorkDir`: Last working directory remembered from previous runs - -#### Configuration Object -- `name`: Unique identifier for the configuration -- `env`: Object containing environment variables for Claude CLI: - - `ANTHROPIC_BASE_URL`: Base URL for the Claude API endpoint - - `ANTHROPIC_AUTH_TOKEN`: Authentication token for the API - - `ANTHROPIC_MODEL`: Default model to use - - And other Claude CLI recognized environment variables -- `skipPermissions`: Per-configuration setting that overrides global `skipPermissions` -- `status`: (optional) API status - "active", "inactive", or "unknown" - -### Example Configurations - -See `config.json.example` for a complete example with multiple providers: - -```bash -cp config.json.example config.json -# Edit config.json with your actual tokens -``` +> **Backward compatibility**: Old config files using `"configs"` instead of `"profiles"` are automatically migrated on load. + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `profiles` | array | API profile configurations | +| `default` | string | Name of the default profile | +| `skipPermissions` | bool | Global `--dangerously-skip-permissions` flag | +| `defaultBehavior` | string | Startup directory: `current`, `last`, or `home` | +| `terminal` | string | Terminal emulator: `terminal`, `iterm`, `warp`, or custom | +| `projects` | object | Project name → directory path mappings | +| `lastWorkDir` | string | Last working directory (auto-saved) | + +### Profile Fields + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Unique profile identifier | +| `env` | object | Environment variables for Claude CLI | +| `skipPermissions` | bool | Per-profile override (optional) | +| `status` | string | `active`, `inactive`, or `unknown` | + +### Supported Environment Variables + +| Variable | Description | +|----------|-------------| +| `ANTHROPIC_BASE_URL` | API endpoint URL | +| `ANTHROPIC_AUTH_TOKEN` | Authentication token | +| `ANTHROPIC_API_KEY` | API key (alternative auth) | +| `ANTHROPIC_MODEL` | Default model | +| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | Haiku model override | +| `ANTHROPIC_DEFAULT_SONNET_MODEL` | Sonnet model override | +| `ANTHROPIC_DEFAULT_OPUS_MODEL` | Opus model override | +| `MAX_THINKING_TOKENS` | Maximum thinking tokens | +| `HTTP_PROXY` / `HTTPS_PROXY` | Proxy settings | +| `NO_PROXY` | Proxy bypass list | ## Development @@ -426,101 +333,72 @@ cp config.json.example config.json ``` codes/ -├── cmd/codes/ # Main entry point +├── cmd/codes/ # Main entry point ├── internal/ -│ ├── commands/ # Command implementations -│ ├── config/ # Configuration management -│ └── ui/ # User interface utilities -├── .github/workflows/ # CI/CD pipelines -├── Makefile # Build automation -└── config.json.example # Example configuration +│ ├── commands/ # CLI command implementations +│ ├── config/ # Configuration management +│ ├── mcp/ # MCP server (stdio transport) +│ ├── output/ # JSON output mode +│ ├── session/ # Terminal session manager +│ ├── tui/ # Interactive TUI (bubbletea) +│ └── ui/ # CLI output utilities +├── .github/workflows/ # CI/CD pipelines +├── Makefile # Build automation +└── config.json.example # Example configuration ``` ### Building ```bash -# Build the binary -make build - -# Run tests -make test - -# Clean build artifacts -make clean - -# Display version -make version -``` - -### Running Tests - -```bash -make test +make build # Build binary +make test # Run tests +make clean # Clean artifacts ``` ## CI/CD -This project uses GitHub Actions for continuous integration and automated releases: - -- **CI Pipeline**: Runs on every push to `main` and pull requests -- **Release Pipeline**: Triggered by version tags (e.g., `v1.0.0`) +GitHub Actions pipelines: -### Creating a Release +- **CI**: Runs on every push to `main` and pull requests +- **Release**: Triggered by version tags (`v*`) ```bash -# Tag a new version git tag v1.0.0 git push origin v1.0.0 ``` -This will automatically build binaries for all supported platforms and create a GitHub release. - ## Requirements -- **Go**: 1.21 or later (for building from source) +- **Go**: 1.24 or later (for building from source) - **npm**: Required for installing/updating Claude CLI -- **Claude CLI**: Automatically installed by the tool if not present +- **Claude CLI**: Automatically installed if not present ## Troubleshooting ### Claude CLI Not Found -If the tool can't find Claude CLI, run: - ```bash codes update ``` -This will install the latest version of Claude CLI via npm. - ### API Connection Failed -If API validation fails when adding a configuration: - -1. Verify your `ANTHROPIC_BASE_URL` is correct -2. Check that your `ANTHROPIC_AUTH_TOKEN` is valid -3. Ensure you have network connectivity to the API endpoint +1. Verify `ANTHROPIC_BASE_URL` is correct +2. Check `ANTHROPIC_AUTH_TOKEN` is valid +3. Ensure network connectivity to the API endpoint -The configuration will still be saved but marked as "inactive" if validation fails. +The profile will be saved but marked as "inactive" if validation fails. ### Permission Denied (Linux/macOS) -If you get permission errors during installation: - ```bash -# Use sudo for system-wide installation -sudo ./codes init - -# Or install to user directory -mkdir -p ~/bin -cp codes ~/bin/ -export PATH="$HOME/bin:$PATH" +sudo ./codes init # system-wide +# or +mkdir -p ~/bin && cp codes ~/bin/ # user directory ``` ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. - 1. Fork the repository 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 3. Commit your changes (`git commit -m 'Add some amazing feature'`) @@ -529,7 +407,7 @@ Contributions are welcome! Please feel free to submit a Pull Request. ## License -[MIT License](LICENSE) - feel free to use this project for any purpose. +[MIT License](LICENSE) ## Acknowledgments diff --git a/cmd/codes/main.go b/cmd/codes/main.go index b200dd5..82ed54c 100644 --- a/cmd/codes/main.go +++ b/cmd/codes/main.go @@ -5,10 +5,15 @@ import ( "os/exec" "github.com/spf13/cobra" + "golang.org/x/term" "codes/internal/commands" + "codes/internal/output" + "codes/internal/tui" ) +var jsonFlag bool + var rootCmd = &cobra.Command{ Use: "codes", Short: "A CLI tool to manage Claude environments and versions", @@ -16,6 +21,8 @@ var rootCmd = &cobra.Command{ } func init() { + rootCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "Output in JSON format") + rootCmd.AddCommand(commands.InitCmd) rootCmd.AddCommand(commands.AddCmd) rootCmd.AddCommand(commands.SelectCmd) @@ -28,20 +35,43 @@ func init() { rootCmd.AddCommand(commands.DefaultBehaviorCmd) rootCmd.AddCommand(commands.SkipPermissionsCmd) rootCmd.AddCommand(commands.CompletionCmd) + rootCmd.AddCommand(commands.ServeCmd) + rootCmd.AddCommand(commands.TerminalCmd) - // 设置默认运行时行为 - 现在使用智能启动 + // 设置默认运行时行为 rootCmd.Run = func(cmd *cobra.Command, args []string) { - // Check if claude is installed + // Set JSON mode from flag + output.JSONMode = jsonFlag + + // If --json flag, output project list in JSON + if jsonFlag { + commands.RunProjectList() + return + } + + // If stdin is a TTY, launch TUI (sessions managed inside TUI) + if term.IsTerminal(int(os.Stdin.Fd())) { + if err := tui.Run(); err != nil { + os.Exit(1) + } + return + } + + // Non-TTY fallback: original behavior if _, err := exec.LookPath("claude"); err != nil { commands.RunClaudeWithConfig([]string{}) return } - // 使用 start 命令的逻辑,支持目录记忆 commands.RunStart(args) } } func main() { + // Propagate --json flag before execution + rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + output.JSONMode = jsonFlag + } + if err := rootCmd.Execute(); err != nil { os.Exit(1) } diff --git a/go.mod b/go.mod index 0336a80..ed37c86 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,42 @@ module codes -go 1.21.3 +go 1.24.2 -require github.com/spf13/cobra v1.10.1 +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/modelcontextprotocol/go-sdk v1.3.0 + github.com/spf13/cobra v1.10.1 + golang.org/x/term v0.40.0 +) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index e613680..584a317 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,86 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs= +github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/commands/cobra.go b/internal/commands/cobra.go index fb83132..f49d586 100644 --- a/internal/commands/cobra.go +++ b/internal/commands/cobra.go @@ -48,7 +48,7 @@ var TestCmd = &cobra.Command{ Short: "Test API configuration", Long: "Test API connectivity for all configurations or a specific one", Args: cobra.MaximumNArgs(1), - ValidArgsFunction: completeConfigNames, + ValidArgsFunction: completeProfileNames, Run: func(cmd *cobra.Command, args []string) { RunTest(args) }, @@ -283,9 +283,52 @@ func init() { SkipPermissionsCmd.AddCommand(SkipPermissionsSetCmd) SkipPermissionsCmd.AddCommand(SkipPermissionsGetCmd) SkipPermissionsCmd.AddCommand(SkipPermissionsResetCmd) + + TerminalCmd.AddCommand(TerminalSetCmd) + TerminalCmd.AddCommand(TerminalGetCmd) + TerminalCmd.AddCommand(TerminalListCmd) } // CompletionCmd generates shell completion scripts +// TerminalCmd represents the terminal command +var TerminalCmd = &cobra.Command{ + Use: "terminal", + Short: "Manage terminal emulator setting", + Long: "Configure which terminal emulator to use for Claude Code sessions", +} + +// TerminalSetCmd sets the terminal emulator +var TerminalSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set the terminal emulator", + Long: "Set which terminal emulator to use: 'terminal' (Terminal.app), 'iterm' (iTerm2), 'warp' (Warp), or a custom command", + Args: cobra.ExactArgs(1), + ValidArgs: []string{"terminal", "iterm", "warp"}, + Run: func(cmd *cobra.Command, args []string) { + RunTerminalSet(args[0]) + }, +} + +// TerminalGetCmd shows the current terminal emulator +var TerminalGetCmd = &cobra.Command{ + Use: "get", + Short: "Show current terminal emulator", + Long: "Show which terminal emulator is configured", + Run: func(cmd *cobra.Command, args []string) { + RunTerminalGet() + }, +} + +// TerminalListCmd lists available terminal options +var TerminalListCmd = &cobra.Command{ + Use: "list", + Short: "List available terminal options", + Long: "List known terminal emulator options", + Run: func(cmd *cobra.Command, args []string) { + RunTerminalList() + }, +} + var CompletionCmd = &cobra.Command{ Use: "completion [bash|zsh|fish|powershell]", Short: "Generate shell completion script", @@ -320,14 +363,24 @@ Usage examples: }, } -// completeConfigNames provides dynamic completion for API config names -func completeConfigNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { +// ServeCmd represents the serve command for MCP server mode +var ServeCmd = &cobra.Command{ + Use: "serve", + Short: "Start MCP server mode", + Long: "Start codes as an MCP server over stdio for integration with Claude Code", + Run: func(cmd *cobra.Command, args []string) { + RunServe() + }, +} + +// completeProfileNames provides dynamic completion for API profile names +func completeProfileNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { cfg, err := config.LoadConfig() if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } var names []string - for _, c := range cfg.Configs { + for _, c := range cfg.Profiles { names = append(names, c.Name) } return names, cobra.ShellCompDirectiveNoFileComp diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 75ff7ee..cbe6629 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -13,6 +13,8 @@ import ( "strings" "codes/internal/config" + mcpserver "codes/internal/mcp" + "codes/internal/output" "codes/internal/ui" ) @@ -37,10 +39,10 @@ func RunSelect() { } fmt.Println() - ui.ShowHeader("Available Claude Configurations") + ui.ShowHeader("Available Claude Profiles") fmt.Println() - for i, c := range cfg.Configs { + for i, c := range cfg.Profiles { apiURL := c.Env["ANTHROPIC_BASE_URL"] if apiURL == "" { apiURL = "unknown" @@ -83,8 +85,8 @@ func RunSelect() { return } - if selectedIdx, err := strconv.Atoi(selection); err == nil && selectedIdx >= 1 && selectedIdx <= len(cfg.Configs) { - selectedConfig := cfg.Configs[selectedIdx-1] + if selectedIdx, err := strconv.Atoi(selection); err == nil && selectedIdx >= 1 && selectedIdx <= len(cfg.Profiles) { + selectedConfig := cfg.Profiles[selectedIdx-1] cfg.Default = selectedConfig.Name // Save config @@ -197,7 +199,7 @@ func RunAdd() { configData = *cfg } else { // 创建新的配置 - configData.Configs = []config.APIConfig{} + configData.Profiles = []config.APIConfig{} } reader := bufio.NewReader(os.Stdin) @@ -212,7 +214,7 @@ func RunAdd() { } // 检查名称是否已存在 - for _, c := range configData.Configs { + for _, c := range configData.Profiles { if c.Name == name { ui.ShowError("Configuration '%s' already exists", fmt.Errorf("name '%s' already exists", name)) return @@ -370,10 +372,10 @@ func RunAdd() { } // 添加新配置 - configData.Configs = append(configData.Configs, newConfig) + configData.Profiles = append(configData.Profiles, newConfig) // 如果这是第一个配置,设置为默认 - if len(configData.Configs) == 1 { + if len(configData.Profiles) == 1 { configData.Default = name ui.ShowInfo("Set '%s' as default configuration", name) } @@ -416,7 +418,7 @@ func RunClaudeWithConfig(args []string) { // Find selected config var selectedConfig config.APIConfig - for _, c := range cfg.Configs { + for _, c := range cfg.Profiles { if c.Name == cfg.Default { selectedConfig = c break @@ -715,7 +717,7 @@ func RunInit() { testConfig.Env["ANTHROPIC_AUTH_TOKEN"] = authToken var cfg config.Config - cfg.Configs = []config.APIConfig{testConfig} + cfg.Profiles = []config.APIConfig{testConfig} cfg.Default = name if config.TestAPIConfig(testConfig) { @@ -726,7 +728,7 @@ func RunInit() { testConfig.Status = "inactive" } - cfg.Configs[0] = testConfig + cfg.Profiles[0] = testConfig // Save configuration if err := config.SaveConfig(&cfg); err != nil { @@ -759,17 +761,17 @@ func RunInit() { ui.ShowWarning(" Your config file may be corrupted") allGood = false } else { - if len(cfg.Configs) == 0 { + if len(cfg.Profiles) == 0 { ui.ShowWarning("✗ No configurations found in config file") ui.ShowWarning(" Run 'codes add' to add a configuration") allGood = false } else { - ui.ShowSuccess("Found %d configuration(s)", len(cfg.Configs)) + ui.ShowSuccess("Found %d configuration(s)", len(cfg.Profiles)) // Show configurations with status fmt.Println() ui.ShowInfo("Configurations:") - for i, c := range cfg.Configs { + for i, c := range cfg.Profiles { isDefault := "" if c.Name == cfg.Default { isDefault = " (default)" @@ -826,9 +828,9 @@ func RunInit() { ui.ShowInfo("Testing default configuration '%s'...", cfg.Default) var defaultConfig *config.APIConfig - for i := range cfg.Configs { - if cfg.Configs[i].Name == cfg.Default { - defaultConfig = &cfg.Configs[i] + for i := range cfg.Profiles { + if cfg.Profiles[i].Name == cfg.Default { + defaultConfig = &cfg.Profiles[i] break } } @@ -1028,10 +1030,23 @@ func RunProjectRemove(name string) { func RunProjectList() { projects, err := config.ListProjects() if err != nil { + if output.JSONMode { + output.PrintError(err) + return + } ui.ShowError("Failed to load projects", err) return } + if output.JSONMode { + infos := make([]config.ProjectInfo, 0, len(projects)) + for name, path := range projects { + infos = append(infos, config.GetProjectInfo(name, path)) + } + output.Print(infos, nil) + return + } + if len(projects) == 0 { ui.ShowInfo("No projects configured yet") ui.ShowInfo("Add a project with: codes project add ") @@ -1062,36 +1077,10 @@ func runClaudeInDirectory(dir string) { // 调用更新检查 checkForUpdates() - // Load and apply config - cfg, err := config.LoadConfig() - if err != nil { - ui.ShowError("Error loading config", err) - os.Exit(1) - } - - // Find selected config - var selectedConfig config.APIConfig - for _, c := range cfg.Configs { - if c.Name == cfg.Default { - selectedConfig = c - break - } - } - - // Set environment variables - config.SetEnvironmentVarsWithConfig(&selectedConfig) + cmd := config.BuildClaudeCmd(dir) - ui.ShowInfo("Using configuration: %s", selectedConfig.Name) ui.ShowInfo("Working directory: %s", dir) - // Build claude command with or without --dangerously-skip-permissions - var claudeArgs []string - if config.ShouldSkipPermissionsWithConfig(&selectedConfig, cfg) { - claudeArgs = []string{"--dangerously-skip-permissions"} - } - - cmd := exec.Command("claude", claudeArgs...) - cmd.Dir = dir // 设置工作目录,而不是作为参数传递 cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -1110,7 +1099,7 @@ func RunTest(args []string) { return } - if len(cfg.Configs) == 0 { + if len(cfg.Profiles) == 0 { ui.ShowError("No configurations found", nil) ui.ShowInfo("Run 'codes add' to add a configuration first") return @@ -1121,9 +1110,9 @@ func RunTest(args []string) { // 测试特定配置 configName := args[0] var targetConfig *config.APIConfig - for i := range cfg.Configs { - if cfg.Configs[i].Name == configName { - targetConfig = &cfg.Configs[i] + for i := range cfg.Profiles { + if cfg.Profiles[i].Name == configName { + targetConfig = &cfg.Profiles[i] break } } @@ -1137,8 +1126,8 @@ func RunTest(args []string) { testSingleConfiguration(targetConfig) } else { // 测试所有配置 - ui.ShowInfo("Testing all %d configurations...", len(cfg.Configs)) - testAllConfigurations(cfg.Configs) + ui.ShowInfo("Testing all %d configurations...", len(cfg.Profiles)) + testAllConfigurations(cfg.Profiles) } } @@ -1178,9 +1167,9 @@ func testSingleConfiguration(apiConfig *config.APIConfig) { } // 更新配置状态 - for i := range cfg.Configs { - if cfg.Configs[i].Name == apiConfig.Name { - cfg.Configs[i].Status = apiConfig.Status + for i := range cfg.Profiles { + if cfg.Profiles[i].Name == apiConfig.Name { + cfg.Profiles[i].Status = apiConfig.Status break } } @@ -1248,10 +1237,10 @@ func testAllConfigurations(configs []config.APIConfig) { // 更新所有配置状态 updated := false - for i := range cfg.Configs { - if newStatus, ok := statuses[cfg.Configs[i].Name]; ok { - if cfg.Configs[i].Status != newStatus { - cfg.Configs[i].Status = newStatus + for i := range cfg.Profiles { + if newStatus, ok := statuses[cfg.Profiles[i].Name]; ok { + if cfg.Profiles[i].Status != newStatus { + cfg.Profiles[i].Status = newStatus updated = true } } @@ -1494,3 +1483,91 @@ func RunSkipPermissionsReset() { ui.ShowInfo("New setting: false (default)") ui.ShowInfo("Claude will now run without --dangerously-skip-permissions unless a specific configuration has it enabled.") } + +// RunServe starts the MCP server mode. +func RunServe() { + if err := mcpserver.RunServer(); err != nil { + // EOF is expected when client disconnects + if err.Error() != "server is closing: EOF" { + ui.ShowError("MCP server error", err) + os.Exit(1) + } + } +} + +// RunTerminalSet sets the terminal emulator preference. +func RunTerminalSet(terminal string) { + old := config.GetTerminal() + if old == "" { + old = "terminal" + } + + if err := config.SetTerminal(terminal); err != nil { + ui.ShowError("Error saving config", err) + return + } + + ui.ShowSuccess("Terminal set to: %s", terminal) + fmt.Println() + ui.ShowInfo("Previous: %s", old) + ui.ShowInfo("New: %s", terminal) + fmt.Println() + + switch terminal { + case "terminal": + ui.ShowInfo("Sessions will open in Terminal.app") + case "iterm", "iterm2": + ui.ShowInfo("Sessions will open in iTerm2") + case "warp": + ui.ShowInfo("Sessions will open in Warp") + default: + ui.ShowInfo("Sessions will open with: %s", terminal) + } +} + +// RunTerminalGet shows the current terminal emulator setting. +func RunTerminalGet() { + current := config.GetTerminal() + if current == "" { + current = "terminal" + } + + fmt.Println("Current terminal emulator:") + ui.ShowInfo(" %s", current) + fmt.Println() + ui.ShowInfo("To change: codes terminal set ") + ui.ShowInfo("To list options: codes terminal list") +} + +// RunTerminalList lists available terminal emulator options. +func RunTerminalList() { + current := config.GetTerminal() + if current == "" { + current = "terminal" + } + + fmt.Println("Available terminal emulators:") + fmt.Println() + + options := []struct { + name string + desc string + }{ + {"terminal", "macOS Terminal.app (default)"}, + {"iterm", "iTerm2"}, + {"warp", "Warp"}, + } + + for _, opt := range options { + marker := " " + if opt.name == current { + marker = "► " + } + ui.ShowInfo("%s%-10s %s", marker, opt.name, opt.desc) + } + + fmt.Println() + ui.ShowInfo("You can also use any custom terminal command:") + ui.ShowInfo(" codes terminal set Alacritty") + ui.ShowInfo(" codes terminal set /usr/bin/xterm") +} diff --git a/internal/config/config.go b/internal/config/config.go index 0df785b..1d98026 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,17 +6,20 @@ import ( "fmt" "net/http" "os" + "os/exec" "path/filepath" + "strings" "time" ) type Config struct { - Configs []APIConfig `json:"configs"` + Profiles []APIConfig `json:"profiles"` Default string `json:"default"` SkipPermissions bool `json:"skipPermissions,omitempty"` // 全局是否跳过权限检查 Projects map[string]string `json:"projects,omitempty"` // 项目别名 -> 目录路径 LastWorkDir string `json:"lastWorkDir,omitempty"` // 上次工作目录 DefaultBehavior string `json:"defaultBehavior,omitempty"` // 默认启动行为: "current", "last", "home" + Terminal string `json:"terminal,omitempty"` // 终端模拟器: "terminal", "iterm", "warp", 或自定义命令 } type APIConfig struct { @@ -63,6 +66,30 @@ func (a *APIConfig) UnmarshalJSON(data []byte) error { return nil } +// UnmarshalJSON implements custom JSON unmarshaling for Config to support +// backward compatibility with the old "configs" field name. +func (c *Config) UnmarshalJSON(data []byte) error { + type Alias Config + aux := &struct { + *Alias + // Legacy field name + Configs []APIConfig `json:"configs"` + }{ + Alias: (*Alias)(c), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + // Migrate legacy "configs" field into "profiles" + if len(c.Profiles) == 0 && len(aux.Configs) > 0 { + c.Profiles = aux.Configs + } + + return nil +} + var ConfigPath string func init() { @@ -368,6 +395,82 @@ func SetEnvironmentVarsWithConfig(apiConfig *APIConfig) { } } +// BuildClaudeCmd creates an *exec.Cmd for launching Claude Code in the given directory. +// It loads the current config, sets environment variables, and applies skip-permissions if configured. +func BuildClaudeCmd(dir string) *exec.Cmd { + cfg, _ := LoadConfig() + + var selected APIConfig + if cfg != nil { + for _, c := range cfg.Profiles { + if c.Name == cfg.Default { + selected = c + break + } + } + } + + SetEnvironmentVarsWithConfig(&selected) + + var args []string + if ShouldSkipPermissionsWithConfig(&selected, cfg) { + args = []string{"--dangerously-skip-permissions"} + } + + cmd := exec.Command("claude", args...) + cmd.Dir = dir + return cmd +} + +// ClaudeCmdSpec returns the command arguments and environment variables for launching +// Claude Code without modifying the current process environment. +func ClaudeCmdSpec() (args []string, env map[string]string) { + cfg, _ := LoadConfig() + + var selected APIConfig + if cfg != nil { + for _, c := range cfg.Profiles { + if c.Name == cfg.Default { + selected = c + break + } + } + } + + env = GetEnvironmentVars(&selected) + + if ShouldSkipPermissionsWithConfig(&selected, cfg) { + args = []string{"--dangerously-skip-permissions"} + } + + return args, env +} + +// GetTerminal returns the configured terminal emulator name. +// Returns empty string if not configured (uses platform default). +func GetTerminal() string { + cfg, err := LoadConfig() + if err != nil || cfg == nil { + return "" + } + return cfg.Terminal +} + +// SetTerminal saves the terminal emulator preference. +func SetTerminal(terminal string) error { + cfg, err := LoadConfig() + if err != nil { + return err + } + cfg.Terminal = terminal + return SaveConfig(cfg) +} + +// TerminalOptions returns the list of known terminal emulator options. +func TerminalOptions() []string { + return []string{"terminal", "iterm", "warp"} +} + // GetDefaultEnvironmentVars 获取默认的环境变量提示 func GetDefaultEnvironmentVars() map[string]string { return map[string]string{ @@ -406,3 +509,82 @@ func GetDefaultBehavior() string { // 无效值,回退到默认 return "current" } + +// ProjectInfo holds detailed information about a registered project. +type ProjectInfo struct { + Name string `json:"name"` + Path string `json:"path"` + Exists bool `json:"exists"` + GitBranch string `json:"gitBranch,omitempty"` + GitDirty bool `json:"gitDirty"` + HasClaudeMD bool `json:"hasClaudeMd"` + RecentBranches []string `json:"recentBranches,omitempty"` +} + +// GetProjectInfo aggregates project metadata including git status and file checks. +func GetProjectInfo(name, path string) ProjectInfo { + info := ProjectInfo{ + Name: name, + Path: path, + } + + if _, err := os.Stat(path); err != nil { + return info + } + info.Exists = true + + info.GitBranch = getGitBranch(path) + info.GitDirty = isGitDirty(path) + info.HasClaudeMD = hasClaudeMD(path) + info.RecentBranches = getRecentGitBranches(path, 5) + + return info +} + +func getGitBranch(dir string) string { + cmd := exec.Command("git", "branch", "--show-current") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func isGitDirty(dir string) bool { + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return false + } + return len(strings.TrimSpace(string(out))) > 0 +} + +func getRecentGitBranches(dir string, n int) []string { + cmd := exec.Command("git", "branch", "--sort=-committerdate", "--format=%(refname:short)") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return nil + } + + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") { + return nil + } + if len(lines) > n { + lines = lines[:n] + } + return lines +} + +func hasClaudeMD(dir string) bool { + if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); err == nil { + return true + } + if _, err := os.Stat(filepath.Join(dir, ".claude", "CLAUDE.md")); err == nil { + return true + } + return false +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..75fbd73 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,51 @@ +package mcpserver + +import ( + "context" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// RunServer starts the MCP server over stdio transport. +func RunServer() error { + server := mcpsdk.NewServer( + &mcpsdk.Implementation{ + Name: "codes", + Version: "1.0.0", + }, + nil, + ) + + // Register tools + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "list_projects", + Description: "List all configured project aliases with their paths and git status", + }, listProjectsHandler) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "add_project", + Description: "Add a new project alias mapping a name to a directory path", + }, addProjectHandler) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "remove_project", + Description: "Remove a project alias by name", + }, removeProjectHandler) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "list_profiles", + Description: "List all API profiles with their status and settings", + }, listProfilesHandler) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "switch_profile", + Description: "Switch the default API profile", + }, switchProfileHandler) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "get_project_info", + Description: "Get detailed information about a project including git status and branch info", + }, getProjectInfoHandler) + + return server.Run(context.Background(), &mcpsdk.StdioTransport{}) +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go new file mode 100644 index 0000000..56c7134 --- /dev/null +++ b/internal/mcp/tools.go @@ -0,0 +1,165 @@ +package mcpserver + +import ( + "context" + "fmt" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "codes/internal/config" +) + +// list_projects + +type listProjectsInput struct{} + +type listProjectsOutput struct { + Projects []config.ProjectInfo `json:"projects"` +} + +func listProjectsHandler(ctx context.Context, req *mcpsdk.CallToolRequest, input listProjectsInput) (*mcpsdk.CallToolResult, listProjectsOutput, error) { + projects, err := config.ListProjects() + if err != nil { + return nil, listProjectsOutput{}, fmt.Errorf("failed to list projects: %w", err) + } + + infos := make([]config.ProjectInfo, 0, len(projects)) + for name, path := range projects { + infos = append(infos, config.GetProjectInfo(name, path)) + } + + return nil, listProjectsOutput{Projects: infos}, nil +} + +// add_project + +type addProjectInput struct { + Name string `json:"name" jsonschema:"Project alias name"` + Path string `json:"path" jsonschema:"Absolute path to project directory"` +} + +type addProjectOutput struct { + Added bool `json:"added"` +} + +func addProjectHandler(ctx context.Context, req *mcpsdk.CallToolRequest, input addProjectInput) (*mcpsdk.CallToolResult, addProjectOutput, error) { + if input.Name == "" || input.Path == "" { + return nil, addProjectOutput{}, fmt.Errorf("name and path are required") + } + if err := config.AddProject(input.Name, input.Path); err != nil { + return nil, addProjectOutput{}, fmt.Errorf("failed to add project: %w", err) + } + return nil, addProjectOutput{Added: true}, nil +} + +// remove_project + +type removeProjectInput struct { + Name string `json:"name" jsonschema:"Project alias name to remove"` +} + +type removeProjectOutput struct { + Removed bool `json:"removed"` +} + +func removeProjectHandler(ctx context.Context, req *mcpsdk.CallToolRequest, input removeProjectInput) (*mcpsdk.CallToolResult, removeProjectOutput, error) { + if err := config.RemoveProject(input.Name); err != nil { + return nil, removeProjectOutput{}, fmt.Errorf("failed to remove project: %w", err) + } + return nil, removeProjectOutput{Removed: true}, nil +} + +// list_profiles + +type listProfilesInput struct{} + +type profileInfo struct { + Name string `json:"name"` + Status string `json:"status"` + SkipPermissions bool `json:"skipPermissions"` + EnvCount int `json:"envCount"` +} + +type listProfilesOutput struct { + Profiles []profileInfo `json:"profiles"` + Default string `json:"default"` +} + +func listProfilesHandler(ctx context.Context, req *mcpsdk.CallToolRequest, input listProfilesInput) (*mcpsdk.CallToolResult, listProfilesOutput, error) { + cfg, err := config.LoadConfig() + if err != nil { + return nil, listProfilesOutput{}, fmt.Errorf("failed to load config: %w", err) + } + + infos := make([]profileInfo, 0, len(cfg.Profiles)) + for _, c := range cfg.Profiles { + skip := false + if c.SkipPermissions != nil { + skip = *c.SkipPermissions + } + infos = append(infos, profileInfo{ + Name: c.Name, + Status: c.Status, + SkipPermissions: skip, + EnvCount: len(c.Env), + }) + } + + return nil, listProfilesOutput{Profiles: infos, Default: cfg.Default}, nil +} + +// switch_profile + +type switchProfileInput struct { + Name string `json:"name" jsonschema:"Profile name to set as default"` +} + +type switchProfileOutput struct { + Switched bool `json:"switched"` + Default string `json:"default"` +} + +func switchProfileHandler(ctx context.Context, req *mcpsdk.CallToolRequest, input switchProfileInput) (*mcpsdk.CallToolResult, switchProfileOutput, error) { + cfg, err := config.LoadConfig() + if err != nil { + return nil, switchProfileOutput{}, fmt.Errorf("failed to load config: %w", err) + } + + found := false + for _, c := range cfg.Profiles { + if c.Name == input.Name { + found = true + break + } + } + if !found { + return nil, switchProfileOutput{}, fmt.Errorf("profile %q not found", input.Name) + } + + cfg.Default = input.Name + if err := config.SaveConfig(cfg); err != nil { + return nil, switchProfileOutput{}, fmt.Errorf("failed to save config: %w", err) + } + + return nil, switchProfileOutput{Switched: true, Default: input.Name}, nil +} + +// get_project_info + +type getProjectInfoInput struct { + Name string `json:"name" jsonschema:"Project alias name"` +} + +type getProjectInfoOutput struct { + config.ProjectInfo +} + +func getProjectInfoHandler(ctx context.Context, req *mcpsdk.CallToolRequest, input getProjectInfoInput) (*mcpsdk.CallToolResult, getProjectInfoOutput, error) { + path, exists := config.GetProjectPath(input.Name) + if !exists { + return nil, getProjectInfoOutput{}, fmt.Errorf("project %q not found", input.Name) + } + + info := config.GetProjectInfo(input.Name, path) + return nil, getProjectInfoOutput{ProjectInfo: info}, nil +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..95a2c34 --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,42 @@ +package output + +import ( + "encoding/json" + "fmt" + "os" +) + +// JSONMode controls whether output is JSON or human-readable +var JSONMode bool + +// Result represents a generic result for JSON output +type Result struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// Print outputs data. In JSON mode, marshals to JSON. Otherwise calls the textFn. +func Print(data interface{}, textFn func()) { + if JSONMode { + out, err := json.MarshalIndent(Result{Success: true, Data: data}, "", " ") + if err != nil { + PrintError(err) + return + } + fmt.Println(string(out)) + return + } + textFn() +} + +// PrintError outputs an error. In JSON mode, marshals error to JSON. +func PrintError(err error) { + if JSONMode { + out, _ := json.MarshalIndent(Result{Success: false, Error: err.Error()}, "", " ") + fmt.Println(string(out)) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..1f90be3 --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,258 @@ +package session + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sync" + "time" +) + +// safeIDPattern matches only characters safe for file paths, shell scripts, and AppleScript. +var safeIDPattern = regexp.MustCompile(`[^a-zA-Z0-9_\-.]`) + +// sanitizeID replaces unsafe characters in a session/project name with underscores. +func sanitizeID(name string) string { + return safeIDPattern.ReplaceAllString(name, "_") +} + +// Status represents the state of a session. +type Status int + +const ( + StatusIdle Status = iota // No active session + StatusRunning // Claude is running + StatusExited // Claude has exited +) + +func (s Status) String() string { + switch s { + case StatusIdle: + return "Idle" + case StatusRunning: + return "Running" + case StatusExited: + return "Exited" + default: + return "Unknown" + } +} + +// Session represents a single Claude Code session. +type Session struct { + ID string // unique ID, e.g. "myapp#1" + ProjectName string + ProjectPath string + Status Status + PID int + StartedAt time.Time + + mu sync.Mutex +} + +// Uptime returns how long the session has been running. +func (s *Session) Uptime() time.Duration { + if s.StartedAt.IsZero() { + return 0 + } + return time.Since(s.StartedAt).Truncate(time.Second) +} + +// Manager manages multiple Claude Code sessions. +// Supports multiple sessions per project. +type Manager struct { + sessions map[string]*Session // key = session ID + counter map[string]int // key = project name, value = next counter + mu sync.RWMutex + terminal string // terminal emulator preference +} + +// NewManager creates a new session manager. +func NewManager(terminal string) *Manager { + return &Manager{ + sessions: make(map[string]*Session), + counter: make(map[string]int), + terminal: terminal, + } +} + +// pidFilePath returns the path to the PID file for the given session ID. +func pidFilePath(sessionID string) string { + return filepath.Join(os.TempDir(), fmt.Sprintf("codes-session-%s.pid", sessionID)) +} + +// nextSessionID generates the next session ID for a project (e.g., "myapp-1", "myapp-2"). +// The project name is sanitized to prevent injection in shell scripts and AppleScript. +func (m *Manager) nextSessionID(projectName string) string { + m.counter[projectName]++ + return fmt.Sprintf("%s-%d", sanitizeID(projectName), m.counter[projectName]) +} + +// StartSession launches a new Claude Code session in a new terminal window. +// Always creates a new session (supports multiple per project). +func (m *Manager) StartSession(name, path string, args []string, env map[string]string) (*Session, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := m.nextSessionID(name) + + pid, err := openInTerminal(id, path, args, env, m.terminal) + if err != nil { + return nil, fmt.Errorf("failed to open terminal: %w", err) + } + + s := &Session{ + ID: id, + ProjectName: name, + ProjectPath: path, + Status: StatusRunning, + PID: pid, + StartedAt: time.Now(), + } + m.sessions[id] = s + + // Monitor process exit in background + go func() { + for { + time.Sleep(2 * time.Second) + if !isProcessAlive(pid) { + s.mu.Lock() + s.Status = StatusExited + s.mu.Unlock() + os.Remove(pidFilePath(id)) + return + } + } + }() + + return s, nil +} + +// FocusSession brings the configured terminal to the foreground. +func (m *Manager) FocusSession() { + focusTerminalWindow(m.terminal) +} + +// GetSessionsByProject returns all sessions for a given project name. +func (m *Manager) GetSessionsByProject(name string) []*Session { + m.mu.RLock() + defer m.mu.RUnlock() + + var result []*Session + for _, s := range m.sessions { + if s.ProjectName == name { + result = append(result, s) + } + } + return result +} + +// GetRunningByProject returns running sessions for a given project. +func (m *Manager) GetRunningByProject(name string) []*Session { + m.mu.RLock() + defer m.mu.RUnlock() + + var result []*Session + for _, s := range m.sessions { + if s.ProjectName == name && s.Status == StatusRunning { + result = append(result, s) + } + } + return result +} + +// KillSession terminates a specific session by ID. +func (m *Manager) KillSession(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + s, ok := m.sessions[id] + if !ok { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.Status != StatusRunning || s.PID <= 0 { + s.Status = StatusExited + return nil + } + + err := killProcess(s.PID) + s.Status = StatusExited + os.Remove(pidFilePath(id)) + return err +} + +// KillByProject terminates all sessions for a given project. +func (m *Manager) KillByProject(name string) { + m.mu.Lock() + defer m.mu.Unlock() + + for _, s := range m.sessions { + if s.ProjectName == name { + s.mu.Lock() + if s.Status == StatusRunning && s.PID > 0 { + killProcess(s.PID) + os.Remove(pidFilePath(s.ID)) + } + s.Status = StatusExited + s.mu.Unlock() + } + } +} + +// ListSessions returns all tracked sessions. +func (m *Manager) ListSessions() []*Session { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]*Session, 0, len(m.sessions)) + for _, s := range m.sessions { + result = append(result, s) + } + return result +} + +// RunningCount returns the number of currently running sessions. +func (m *Manager) RunningCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + + count := 0 + for _, s := range m.sessions { + if s.Status == StatusRunning { + count++ + } + } + return count +} + +// RefreshStatus checks all running sessions and updates their status. +func (m *Manager) RefreshStatus() { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, s := range m.sessions { + s.mu.Lock() + if s.Status == StatusRunning && s.PID > 0 && !isProcessAlive(s.PID) { + s.Status = StatusExited + os.Remove(pidFilePath(s.ID)) + } + s.mu.Unlock() + } +} + +// CleanExited removes exited sessions from tracking. +func (m *Manager) CleanExited() { + m.mu.Lock() + defer m.mu.Unlock() + + for id, s := range m.sessions { + if s.Status == StatusExited { + delete(m.sessions, id) + } + } +} diff --git a/internal/session/terminal_darwin.go b/internal/session/terminal_darwin.go new file mode 100644 index 0000000..566b06b --- /dev/null +++ b/internal/session/terminal_darwin.go @@ -0,0 +1,124 @@ +//go:build darwin + +package session + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +// escapeAppleScript escapes a string for safe embedding in AppleScript double-quoted strings. +func escapeAppleScript(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "\\\"") + return s +} + +// openInTerminal opens a new terminal window running Claude Code. +// The terminal parameter selects which app to use: "iterm", "warp", or "" / "terminal" for Terminal.app. +func openInTerminal(sessionID, dir string, args []string, env map[string]string, terminal string) (int, error) { + script, scriptPath := buildScript(sessionID, dir, args, env) + + if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { + return 0, err + } + + // Remove stale PID file + pidFile := pidFilePath(sessionID) + os.Remove(pidFile) + + // Launch in the selected terminal + var err error + switch strings.ToLower(terminal) { + case "iterm", "iterm2": + err = openITerm(scriptPath, sessionID) + case "warp": + err = openWarp(scriptPath) + case "", "terminal": + err = openTerminalApp(scriptPath) + default: + // Custom terminal command (e.g., "/Applications/Alacritty.app/...") + err = openCustom(terminal, scriptPath) + } + + if err != nil { + os.Remove(scriptPath) + return 0, err + } + + // Wait for PID file to appear (up to 5 seconds) + for i := 0; i < 50; i++ { + time.Sleep(100 * time.Millisecond) + data, err := os.ReadFile(pidFile) + if err == nil { + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err == nil && pid > 0 { + return pid, nil + } + } + } + + return 0, fmt.Errorf("timed out waiting for session PID") +} + +func openTerminalApp(scriptPath string) error { + appleScript := fmt.Sprintf(`tell application "Terminal" + activate + do script "bash '%s'" +end tell`, escapeAppleScript(scriptPath)) + + return exec.Command("osascript", "-e", appleScript).Run() +} + +func openITerm(scriptPath, sessionID string) error { + appleScript := fmt.Sprintf(`tell application "iTerm" + activate + set newWindow to (create window with default profile) + tell current session of newWindow + set name to "codes: %s" + write text "bash '%s'" + end tell +end tell`, escapeAppleScript(sessionID), escapeAppleScript(scriptPath)) + + return exec.Command("osascript", "-e", appleScript).Run() +} + +func openWarp(scriptPath string) error { + // Warp doesn't have robust AppleScript support, use open command + return exec.Command("open", "-a", "Warp", scriptPath).Run() +} + +func openCustom(terminal, scriptPath string) error { + // Try as an app name first (e.g., "Alacritty") + if !strings.Contains(terminal, "/") { + if err := exec.Command("open", "-a", terminal, scriptPath).Run(); err == nil { + return nil + } + } + // Try as a direct command + return exec.Command(terminal, "-e", "bash", scriptPath).Run() +} + +// focusTerminalWindow brings the configured terminal to the foreground. +func focusTerminalWindow(terminal string) { + app := "Terminal" + switch strings.ToLower(terminal) { + case "iterm", "iterm2": + app = "iTerm" + case "warp": + app = "Warp" + case "", "terminal": + app = "Terminal" + default: + if !strings.Contains(terminal, "/") { + app = terminal + } else { + return + } + } + exec.Command("osascript", "-e", fmt.Sprintf(`tell application "%s" to activate`, escapeAppleScript(app))).Run() +} diff --git a/internal/session/terminal_linux.go b/internal/session/terminal_linux.go new file mode 100644 index 0000000..e97fd6a --- /dev/null +++ b/internal/session/terminal_linux.go @@ -0,0 +1,88 @@ +//go:build linux + +package session + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +// terminalEmulators lists common Linux terminal emulators and their launch arguments. +var terminalEmulators = []struct { + name string + args func(script string) []string +}{ + {"x-terminal-emulator", func(s string) []string { return []string{"-e", "bash", s} }}, + {"gnome-terminal", func(s string) []string { return []string{"--", "bash", s} }}, + {"konsole", func(s string) []string { return []string{"-e", "bash", s} }}, + {"xfce4-terminal", func(s string) []string { return []string{"-e", "bash " + s} }}, + {"alacritty", func(s string) []string { return []string{"-e", "bash", s} }}, + {"kitty", func(s string) []string { return []string{"bash", s} }}, + {"xterm", func(s string) []string { return []string{"-e", "bash", s} }}, +} + +// openInTerminal opens a new terminal window running Claude Code. +// The terminal parameter can specify a custom terminal emulator command. +func openInTerminal(sessionID, dir string, args []string, env map[string]string, terminal string) (int, error) { + script, scriptPath := buildScript(sessionID, dir, args, env) + + if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { + return 0, err + } + + pidFile := pidFilePath(sessionID) + os.Remove(pidFile) + + var started bool + + // If a specific terminal is configured, try it first + if terminal != "" { + cmd := exec.Command(terminal, "-e", "bash", scriptPath) + if err := cmd.Start(); err == nil { + go cmd.Wait() + started = true + } + } + + // Auto-detect terminal emulator + if !started { + for _, te := range terminalEmulators { + if _, err := exec.LookPath(te.name); err != nil { + continue + } + cmd := exec.Command(te.name, te.args(scriptPath)...) + if err := cmd.Start(); err != nil { + continue + } + go cmd.Wait() + started = true + break + } + } + + if !started { + os.Remove(scriptPath) + return 0, fmt.Errorf("no terminal emulator found") + } + + // Wait for PID file to appear (up to 5 seconds) + for i := 0; i < 50; i++ { + time.Sleep(100 * time.Millisecond) + data, err := os.ReadFile(pidFile) + if err == nil { + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err == nil && pid > 0 { + return pid, nil + } + } + } + + return 0, fmt.Errorf("timed out waiting for session PID") +} + +// focusTerminalWindow is a no-op on Linux (window focusing is WM-dependent). +func focusTerminalWindow(terminal string) {} diff --git a/internal/session/terminal_unix.go b/internal/session/terminal_unix.go new file mode 100644 index 0000000..81e3b73 --- /dev/null +++ b/internal/session/terminal_unix.go @@ -0,0 +1,83 @@ +//go:build !windows + +package session + +import ( + "fmt" + "os" + "regexp" + "strings" + "syscall" +) + +// validEnvVarName matches valid POSIX environment variable names. +var validEnvVarName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +// isProcessAlive checks if a process with the given PID is still running. +func isProcessAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + err = proc.Signal(syscall.Signal(0)) + return err == nil +} + +// killProcess sends SIGTERM to the process, then SIGKILL if needed. +func killProcess(pid int) error { + proc, err := os.FindProcess(pid) + if err != nil { + return err + } + return proc.Signal(syscall.SIGTERM) +} + +// buildScript creates a shell script that launches Claude Code in the given directory. +// The script writes its PID to a file and cleans up on exit. +func buildScript(name, dir string, args []string, env map[string]string) (script string, scriptPath string) { + pidFile := pidFilePath(name) + scriptPath = fmt.Sprintf("%s/codes-%s.sh", os.TempDir(), name) + + var b strings.Builder + b.WriteString("#!/bin/bash\n") + b.WriteString(fmt.Sprintf("# codes session: %s\n\n", name)) + + // Cleanup on exit (removes PID file and this script) + b.WriteString(fmt.Sprintf("cleanup() { rm -f '%s' '%s'; }\n", pidFile, scriptPath)) + b.WriteString("trap cleanup EXIT\n\n") + + // Write PID file + b.WriteString(fmt.Sprintf("echo $$ > '%s'\n\n", pidFile)) + + // Set environment variables + for k, v := range env { + if !validEnvVarName.MatchString(k) { + continue // skip invalid variable names to prevent shell injection + } + escaped := strings.ReplaceAll(v, "'", "'\\''") + b.WriteString(fmt.Sprintf("export %s='%s'\n", k, escaped)) + } + if len(env) > 0 { + b.WriteString("\n") + } + + // Change to project directory + escaped := strings.ReplaceAll(dir, "'", "'\\''") + b.WriteString(fmt.Sprintf("cd '%s'\n\n", escaped)) + + // Set window title + b.WriteString(fmt.Sprintf("echo -ne '\\033]0;codes: %s\\007'\n\n", name)) + + // Run claude + if len(args) > 0 { + quotedArgs := make([]string, len(args)) + for i, a := range args { + quotedArgs[i] = fmt.Sprintf("'%s'", strings.ReplaceAll(a, "'", "'\\''")) + } + b.WriteString(fmt.Sprintf("claude %s\n", strings.Join(quotedArgs, " "))) + } else { + b.WriteString("claude\n") + } + + return b.String(), scriptPath +} diff --git a/internal/session/terminal_windows.go b/internal/session/terminal_windows.go new file mode 100644 index 0000000..a20c358 --- /dev/null +++ b/internal/session/terminal_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package session + +import "fmt" + +func openInTerminal(sessionID, dir string, args []string, env map[string]string, terminal string) (int, error) { + return 0, fmt.Errorf("terminal sessions are not yet supported on Windows") +} + +func focusTerminalWindow(terminal string) {} + +func isProcessAlive(pid int) bool { return false } + +func killProcess(pid int) error { return nil } diff --git a/internal/tui/addform.go b/internal/tui/addform.go new file mode 100644 index 0000000..797ed29 --- /dev/null +++ b/internal/tui/addform.go @@ -0,0 +1,123 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// projectAddedMsg is sent when the user submits the add form. +type projectAddedMsg struct { + name string + path string +} + +// addFormModel is the model for the add-project form. +type addFormModel struct { + nameInput textinput.Model + pathInput textinput.Model + focused int // 0=name, 1=path + err string +} + +// newAddForm creates a new add-project form with two text inputs. +func newAddForm() addFormModel { + ni := textinput.New() + ni.Placeholder = "project-name" + ni.CharLimit = 50 + ni.Focus() + + pi := textinput.New() + pi.Placeholder = "/path/to/project" + pi.CharLimit = 200 + + return addFormModel{ + nameInput: ni, + pathInput: pi, + focused: 0, + } +} + +// focusInput updates Focus/Blur state on the inputs based on m.focused. +func (m *addFormModel) focusInput() { + if m.focused == 0 { + m.nameInput.Focus() + m.pathInput.Blur() + } else { + m.nameInput.Blur() + m.pathInput.Focus() + } +} + +// Update handles input for the add form. +func (m addFormModel) Update(msg tea.Msg) (addFormModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "tab", "down": + m.focused = (m.focused + 1) % 2 + m.focusInput() + return m, nil + case "shift+tab", "up": + m.focused = (m.focused + 1) % 2 + m.focusInput() + return m, nil + case "enter": + name := strings.TrimSpace(m.nameInput.Value()) + path := strings.TrimSpace(m.pathInput.Value()) + if name == "" && path == "" { + m.err = "Name and path are required" + return m, nil + } + if name == "" { + m.err = "Name is required" + return m, nil + } + if path == "" { + m.err = "Path is required" + return m, nil + } + m.err = "" + return m, func() tea.Msg { + return projectAddedMsg{name: name, path: path} + } + } + } + + // Update the focused input. + var cmd tea.Cmd + if m.focused == 0 { + m.nameInput, cmd = m.nameInput.Update(msg) + } else { + m.pathInput, cmd = m.pathInput.Update(msg) + } + return m, cmd +} + +// View renders the add form. +func (m addFormModel) View() string { + var b strings.Builder + + title := lipgloss.NewStyle(). + Bold(true). + Foreground(primaryColor). + MarginBottom(1). + Render("Add Project") + + b.WriteString(title + "\n\n") + b.WriteString(formLabelStyle.Render("Name") + "\n") + b.WriteString(m.nameInput.View() + "\n\n") + b.WriteString(formLabelStyle.Render("Path") + "\n") + b.WriteString(m.pathInput.View() + "\n\n") + + if m.err != "" { + errStyle := lipgloss.NewStyle().Foreground(dangerColor) + b.WriteString(errStyle.Render(m.err) + "\n\n") + } + + b.WriteString(formHintStyle.Render("Tab to switch fields, Enter to add, Esc to cancel")) + + return b.String() +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..bb7c814 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,55 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + Quit key.Binding + Tab key.Binding + Enter key.Binding + Add key.Binding + Delete key.Binding + Escape key.Binding + Help key.Binding +} + +var keys = keyMap{ + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch view"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "open"), + ), + Add: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "add"), + ), + Delete: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "delete"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Quit, k.Tab, k.Enter, k.Add, k.Delete} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Quit, k.Tab, k.Enter}, + {k.Add, k.Delete, k.Escape}, + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..88bb0bd --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,622 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "codes/internal/config" + "codes/internal/session" +) + +type viewState int + +const ( + viewProjects viewState = iota + viewProfiles + viewSettings + viewAddForm + viewAddProfile +) + +type panelFocus int + +const ( + focusLeft panelFocus = iota // project/profile list + focusRight // detail panel (sessions) +) + +// Model is the main TUI model. +type Model struct { + state viewState + focus panelFocus + projectList list.Model + profileList list.Model + addForm addFormModel + profileForm profileFormModel + help help.Model + cfg *config.Config + width int + height int + err string + sessionMgr *session.Manager + sessionCursor int // cursor index within right-panel session list + settings settingsModel +} + +// projectDeletedMsg is sent after deleting a project. +type projectDeletedMsg struct{ name string } + +// profileSwitchedMsg is sent after switching the default profile. +type profileSwitchedMsg struct{ name string } + +// sessionStartedMsg is sent after attempting to start a session. +type sessionStartedMsg struct { + name string + err error +} + +// sessionTickMsg triggers periodic session status refresh. +type sessionTickMsg struct{} + +func sessionTick() tea.Cmd { + return tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return sessionTickMsg{} + }) +} + +// NewModel creates the initial TUI model. +func NewModel() Model { + // Load projects + projectItems := loadProjects() + projectDelegate := list.NewDefaultDelegate() + projectDelegate.ShowDescription = true + pl := list.New(projectItems, projectDelegate, 0, 0) + pl.Title = "Projects" + pl.SetShowHelp(false) + pl.SetShowStatusBar(true) + pl.SetFilteringEnabled(true) + + // Load profiles + profileItems, _ := loadProfiles() + profileDelegate := list.NewDefaultDelegate() + profileDelegate.ShowDescription = true + cl := list.New(profileItems, profileDelegate, 0, 0) + cl.Title = "Profiles" + cl.SetShowHelp(false) + cl.SetShowStatusBar(true) + cl.SetFilteringEnabled(true) + + cfg, _ := config.LoadConfig() + + return Model{ + state: viewProjects, + projectList: pl, + profileList: cl, + help: help.New(), + cfg: cfg, + sessionMgr: session.NewManager(config.GetTerminal()), + settings: newSettingsModel(cfg), + } +} + +func (m Model) Init() tea.Cmd { + return sessionTick() +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + // Account for appStyle padding: Padding(1, 2) = 2 vertical, 4 horizontal + innerWidth := m.width - 4 + innerHeight := m.height - 2 + + // Calculate list dimensions (left panel takes ~50% of inner width) + listWidth := innerWidth / 2 + listHeight := innerHeight - 6 // leave room for header + footer + help + m.projectList.SetSize(listWidth, listHeight) + m.profileList.SetSize(listWidth, listHeight) + m.help.Width = innerWidth + return m, nil + + case tea.KeyMsg: + // Global keys (not when filtering or in form) + if m.state == viewAddForm { + return m.updateAddForm(msg) + } + if m.state == viewAddProfile { + return m.updateProfileForm(msg) + } + if m.state == viewSettings { + return m.updateSettings(msg) + } + + // Right panel focused — handle session selection + if m.focus == focusRight && m.state == viewProjects { + return m.updateRightPanel(msg) + } + + // Don't intercept keys when the current list is filtering + if m.state == viewProjects && m.projectList.FilterState() == list.Filtering { + return m.updateList(msg) + } + if m.state == viewProfiles && m.profileList.FilterState() == list.Filtering { + return m.updateList(msg) + } + + switch { + case msg.String() == "q" || msg.String() == "ctrl+c": + return m, tea.Quit + + case msg.String() == "tab": + switch m.state { + case viewProjects: + m.state = viewProfiles + case viewProfiles: + m.state = viewSettings + m.settings = newSettingsModel(m.cfg) + default: + m.state = viewProjects + } + m.focus = focusLeft + return m, nil + + case msg.String() == "right" || msg.String() == "l": + if m.state == viewProjects && m.focus == focusLeft { + // Only activate right panel if there are running sessions + if item, ok := m.projectList.SelectedItem().(projectItem); ok { + running := m.sessionMgr.GetRunningByProject(item.info.Name) + if len(running) > 0 { + m.focus = focusRight + m.sessionCursor = 0 + return m, nil + } + } + } + + case msg.String() == "a" && m.state == viewProjects: + m.state = viewAddForm + m.addForm = newAddForm() + return m, nil + + case msg.String() == "a" && m.state == viewProfiles: + m.state = viewAddProfile + m.profileForm = newProfileForm() + return m, nil + + case msg.String() == "d" && m.state == viewProjects: + if item, ok := m.projectList.SelectedItem().(projectItem); ok { + return m, func() tea.Msg { + config.RemoveProject(item.info.Name) + return projectDeletedMsg{name: item.info.Name} + } + } + + case msg.String() == "k" && m.state == viewProjects: + if item, ok := m.projectList.SelectedItem().(projectItem); ok { + m.sessionMgr.KillByProject(item.info.Name) + } + return m, nil + + case msg.String() == "t" && m.state == viewProjects: + // Cycle terminal: terminal → iterm → warp → terminal + options := config.TerminalOptions() + current := config.GetTerminal() + if current == "" { + current = "terminal" + } + next := options[0] + for i, opt := range options { + if opt == current && i+1 < len(options) { + next = options[i+1] + break + } + } + config.SetTerminal(next) + m.sessionMgr = session.NewManager(next) + return m, nil + + case msg.String() == "enter": + if m.state == viewProjects { + if item, ok := m.projectList.SelectedItem().(projectItem); ok { + if !item.info.Exists { + return m, nil + } + name := item.info.Name + path := item.info.Path + + // Start a new session + args, env := config.ClaudeCmdSpec() + return m, func() tea.Msg { + _, err := m.sessionMgr.StartSession(name, path, args, env) + return sessionStartedMsg{name: name, err: err} + } + } + } else if m.state == viewProfiles { + if item, ok := m.profileList.SelectedItem().(profileItem); ok { + profileName := item.cfg.Name + return m, func() tea.Msg { + cfg, err := config.LoadConfig() + if err == nil { + cfg.Default = profileName + config.SaveConfig(cfg) + } + return profileSwitchedMsg{name: profileName} + } + } + } + } + + case sessionStartedMsg: + if msg.err != nil { + m.err = fmt.Sprintf("session %s: %v", msg.name, msg.err) + } else { + m.err = "" + } + m.state = viewProjects + return m, nil + + case sessionTickMsg: + m.sessionMgr.RefreshStatus() + return m, sessionTick() + + case projectAddedMsg: + config.AddProject(msg.name, msg.path) + m.state = viewProjects + m.projectList.SetItems(loadProjects()) + m.err = "" + return m, nil + + case projectDeletedMsg: + m.projectList.SetItems(loadProjects()) + return m, nil + + case profileAddedMsg: + // Save the new profile + cfg, err := config.LoadConfig() + if err == nil { + cfg.Profiles = append(cfg.Profiles, msg.cfg) + if len(cfg.Profiles) == 1 { + cfg.Default = msg.cfg.Name + } + config.SaveConfig(cfg) + } + m.state = viewProfiles + items, _ := loadProfiles() + m.profileList.SetItems(items) + m.cfg, _ = config.LoadConfig() + m.err = "" + return m, nil + + case profileSwitchedMsg: + items, _ := loadProfiles() + m.profileList.SetItems(items) + m.cfg, _ = config.LoadConfig() + return m, nil + + case settingChangedMsg: + m.cfg, _ = config.LoadConfig() + return m, nil + } + + return m.updateList(msg) +} + +func (m Model) updateAddForm(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "esc" { + m.state = viewProjects + return m, nil + } + } + + var cmd tea.Cmd + m.addForm, cmd = m.addForm.Update(msg) + return m, cmd +} + +func (m Model) updateProfileForm(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "esc" { + m.state = viewProfiles + return m, nil + } + } + + var cmd tea.Cmd + m.profileForm, cmd = m.profileForm.Update(msg) + return m, cmd +} + +func (m Model) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "tab": + m.state = viewProjects + m.focus = focusLeft + return m, nil + case "up", "k": + if m.settings.cursor > 0 { + m.settings.cursor-- + } + return m, nil + case "down", "j": + if m.settings.cursor < len(m.settings.items)-1 { + m.settings.cursor++ + } + return m, nil + case "enter", " ": + item := &m.settings.items[m.settings.cursor] + if item.options != nil { + // Cycle to next option + for i, opt := range item.options { + if opt == item.value { + item.value = item.options[(i+1)%len(item.options)] + return m, m.applySetting(item.key, item.value) + } + } + // If current value not found in options, set to first + item.value = item.options[0] + return m, m.applySetting(item.key, item.value) + } + return m, nil + } + } + return m, nil +} + +func (m Model) applySetting(key, value string) tea.Cmd { + return func() tea.Msg { + cfg, err := config.LoadConfig() + if err != nil { + return nil + } + switch key { + case "terminal": + cfg.Terminal = value + case "defaultBehavior": + cfg.DefaultBehavior = value + case "skipPermissions": + cfg.SkipPermissions = value == "on" + } + config.SaveConfig(cfg) + return settingChangedMsg{} + } +} + +func (m Model) updateRightPanel(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "left", "h", "esc": + m.focus = focusLeft + return m, nil + + case "up": + if m.sessionCursor > 0 { + m.sessionCursor-- + } + return m, nil + + case "down": + if item, ok := m.projectList.SelectedItem().(projectItem); ok { + running := m.sessionMgr.GetRunningByProject(item.info.Name) + // sessions + "New Session" option + maxIdx := len(running) + if m.sessionCursor < maxIdx { + m.sessionCursor++ + } + } + return m, nil + + case "k": + // Kill the selected session + if item, ok := m.projectList.SelectedItem().(projectItem); ok { + running := m.sessionMgr.GetRunningByProject(item.info.Name) + if m.sessionCursor < len(running) { + m.sessionMgr.KillSession(running[m.sessionCursor].ID) + // Adjust cursor after removal + remaining := m.sessionMgr.GetRunningByProject(item.info.Name) + if len(remaining) == 0 { + m.focus = focusLeft + } else if m.sessionCursor >= len(remaining) { + m.sessionCursor = len(remaining) - 1 + } + } + } + return m, nil + + case "enter": + if item, ok := m.projectList.SelectedItem().(projectItem); ok { + running := m.sessionMgr.GetRunningByProject(item.info.Name) + if m.sessionCursor < len(running) { + // Focus existing session terminal + m.sessionMgr.FocusSession() + m.focus = focusLeft + return m, nil + } + // "New Session" selected + name := item.info.Name + path := item.info.Path + m.focus = focusLeft + args, env := config.ClaudeCmdSpec() + return m, func() tea.Msg { + _, err := m.sessionMgr.StartSession(name, path, args, env) + return sessionStartedMsg{name: name, err: err} + } + } + + case "q", "ctrl+c": + return m, tea.Quit + } + } + return m, nil +} + +func (m Model) updateList(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + if m.state == viewProjects { + m.projectList, cmd = m.projectList.Update(msg) + } else if m.state == viewProfiles { + m.profileList, cmd = m.profileList.Update(msg) + } + return m, cmd +} + +func (m Model) View() string { + if m.width == 0 { + return "Loading..." + } + + var b strings.Builder + + // Inner dimensions after appStyle padding (Padding(1,2) = 4 horizontal, 2 vertical) + innerWidth := m.width - 4 + + // Header + header := m.renderHeader() + b.WriteString(header) + b.WriteString("\n\n") + + if m.state == viewAddForm { + b.WriteString(m.addForm.View()) + } else if m.state == viewAddProfile { + b.WriteString(m.profileForm.View()) + } else if m.state == viewSettings { + // Settings uses full width, no left/right split + contentHeight := m.height - 10 + b.WriteString(m.settings.View(innerWidth, contentHeight)) + } else { + // Main content: left list + right detail + leftWidth := innerWidth / 2 + rightWidth := innerWidth - leftWidth - 2 + contentHeight := m.height - 10 // header + footer + appStyle padding + + var leftPanel, rightPanel string + + if m.state == viewProjects { + leftPanel = m.projectList.View() + if item, ok := m.projectList.SelectedItem().(projectItem); ok { + rightPanel = renderProjectDetail(item.info, rightWidth, contentHeight, m.sessionMgr, m.focus == focusRight, m.sessionCursor) + } + } else { + leftPanel = m.profileList.View() + if item, ok := m.profileList.SelectedItem().(profileItem); ok { + rightPanel = renderProfileDetail(item, rightWidth, contentHeight) + } + } + + content := lipgloss.JoinHorizontal( + lipgloss.Top, + lipgloss.NewStyle().Width(leftWidth).Render(leftPanel), + lipgloss.NewStyle().Width(rightWidth).MarginLeft(2).Render(rightPanel), + ) + b.WriteString(content) + } + + // Error display + if m.err != "" { + b.WriteString("\n") + b.WriteString(statusErrorStyle.Render(" Error: " + m.err)) + } + + // Footer help + b.WriteString("\n") + b.WriteString(helpStyle.Render(m.renderHelp())) + + return appStyle.Render(b.String()) +} + +func (m Model) renderHeader() string { + innerWidth := m.width - 4 + + title := titleStyle.Render(" ⬡ codes ") + + projectTab := inactiveTabStyle.Render("Projects") + profileTab := inactiveTabStyle.Render("Profiles") + settingsTab := inactiveTabStyle.Render("Settings") + + if m.state == viewProjects || m.state == viewAddForm { + projectTab = activeTabStyle.Render("Projects") + } else if m.state == viewProfiles || m.state == viewAddProfile { + profileTab = activeTabStyle.Render("Profiles") + } else if m.state == viewSettings { + settingsTab = activeTabStyle.Render("Settings") + } + + defaultCfg := "" + if m.cfg != nil && m.cfg.Default != "" { + term := config.GetTerminal() + if term == "" { + term = "terminal" + } + defaultCfg = lipgloss.NewStyle(). + Foreground(mutedColor). + Render(fmt.Sprintf("Profile: %s | Term: %s", m.cfg.Default, term)) + } + + // Show running session count + running := m.sessionMgr.RunningCount() + sessionInfo := "" + if running > 0 { + sessionInfo = statusOkStyle.Render(fmt.Sprintf(" [%d running]", running)) + } + + tabs := fmt.Sprintf("%s %s %s", projectTab, profileTab, settingsTab) + gap := strings.Repeat(" ", max(0, innerWidth-lipgloss.Width(title)-lipgloss.Width(tabs)-lipgloss.Width(defaultCfg)-lipgloss.Width(sessionInfo)-8)) + + return fmt.Sprintf("%s %s%s%s%s", title, tabs, gap, sessionInfo, defaultCfg) +} + +func (m Model) renderHelp() string { + if m.state == viewAddForm { + return formHintStyle.Render("Tab: switch fields Enter: add Esc: cancel") + } + if m.state == viewAddProfile { + return formHintStyle.Render("Tab: switch fields Space: toggle Enter: add Esc: cancel") + } + if m.state == viewSettings { + return formHintStyle.Render("↑↓ select Enter/Space cycle tab switch q quit") + } + if m.focus == focusRight && m.state == viewProjects { + return formHintStyle.Render("↑↓ select Enter open k kill ← back q quit") + } + + parts := []string{ + "↑↓ select", + "enter open", + "/ filter", + } + + if m.state == viewProjects { + parts = append(parts, "→ sessions", "a add", "d delete", "k kill", "t terminal") + } + if m.state == viewProfiles { + parts = append(parts, "a add profile") + } + + parts = append(parts, "tab switch", "q quit") + + return strings.Join(parts, " ") +} + +// Run starts the TUI application. +func Run() error { + m := NewModel() + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err := p.Run() + return err +} diff --git a/internal/tui/profileform.go b/internal/tui/profileform.go new file mode 100644 index 0000000..9c03a6e --- /dev/null +++ b/internal/tui/profileform.go @@ -0,0 +1,214 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "codes/internal/config" +) + +// profileAddedMsg is sent when a new profile is submitted. +type profileAddedMsg struct { + cfg config.APIConfig + tested bool // whether API test passed + status string // "active" or "inactive" +} + +// profileTestResultMsg is sent after async API test completes. +type profileTestResultMsg struct { + name string + active bool +} + +type profileFormModel struct { + nameInput textinput.Model + urlInput textinput.Model + tokenInput textinput.Model + focused int // 0=name, 1=url, 2=token, 3=skip toggle + skip bool // skip permissions toggle + err string + testing bool // API test in progress +} + +func newProfileForm() profileFormModel { + ni := textinput.New() + ni.Placeholder = "work" + ni.CharLimit = 50 + ni.Focus() + + ui := textinput.New() + ui.Placeholder = "https://api.anthropic.com" + ui.CharLimit = 200 + + ti := textinput.New() + ti.Placeholder = "sk-ant-..." + ti.CharLimit = 200 + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '•' + + return profileFormModel{ + nameInput: ni, + urlInput: ui, + tokenInput: ti, + focused: 0, + } +} + +func (m *profileFormModel) focusProfileInput() { + m.nameInput.Blur() + m.urlInput.Blur() + m.tokenInput.Blur() + switch m.focused { + case 0: + m.nameInput.Focus() + case 1: + m.urlInput.Focus() + case 2: + m.tokenInput.Focus() + } +} + +func (m profileFormModel) Update(msg tea.Msg) (profileFormModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "tab", "down": + m.focused = (m.focused + 1) % 4 + m.focusProfileInput() + return m, nil + case "shift+tab", "up": + m.focused = (m.focused + 3) % 4 // -1 mod 4 + m.focusProfileInput() + return m, nil + case " ": + if m.focused == 3 { + m.skip = !m.skip + return m, nil + } + case "enter": + if m.testing { + return m, nil + } + name := strings.TrimSpace(m.nameInput.Value()) + url := strings.TrimSpace(m.urlInput.Value()) + token := strings.TrimSpace(m.tokenInput.Value()) + + if name == "" { + m.err = "Name is required" + return m, nil + } + if url == "" { + m.err = "Base URL is required" + return m, nil + } + if token == "" { + m.err = "Auth Token is required" + return m, nil + } + + m.err = "" + m.testing = true + + newCfg := config.APIConfig{ + Name: name, + Env: map[string]string{ + "ANTHROPIC_BASE_URL": url, + "ANTHROPIC_AUTH_TOKEN": token, + }, + } + if m.skip { + skip := true + newCfg.SkipPermissions = &skip + } + + // Test API connection in background + return m, func() tea.Msg { + active := config.TestAPIConfig(newCfg) + status := "inactive" + if active { + status = "active" + } + newCfg.Status = status + return profileAddedMsg{ + cfg: newCfg, + tested: true, + status: status, + } + } + } + } + + // Update focused text input + var cmd tea.Cmd + switch m.focused { + case 0: + m.nameInput, cmd = m.nameInput.Update(msg) + case 1: + m.urlInput, cmd = m.urlInput.Update(msg) + case 2: + m.tokenInput, cmd = m.tokenInput.Update(msg) + } + return m, cmd +} + +func (m profileFormModel) View() string { + var b strings.Builder + + title := lipgloss.NewStyle(). + Bold(true). + Foreground(primaryColor). + MarginBottom(1). + Render("Add Profile") + + b.WriteString(title + "\n\n") + + // Name + label := formLabelStyle.Render("Name") + if m.focused == 0 { + label = lipgloss.NewStyle().Bold(true).Foreground(secondaryColor).Render("▸ Name") + } + b.WriteString(label + "\n") + b.WriteString(m.nameInput.View() + "\n\n") + + // URL + label = formLabelStyle.Render("Base URL") + if m.focused == 1 { + label = lipgloss.NewStyle().Bold(true).Foreground(secondaryColor).Render("▸ Base URL") + } + b.WriteString(label + "\n") + b.WriteString(m.urlInput.View() + "\n\n") + + // Token + label = formLabelStyle.Render("Auth Token") + if m.focused == 2 { + label = lipgloss.NewStyle().Bold(true).Foreground(secondaryColor).Render("▸ Auth Token") + } + b.WriteString(label + "\n") + b.WriteString(m.tokenInput.View() + "\n\n") + + // Skip permissions toggle + toggleLabel := formLabelStyle.Render("Skip Permissions") + if m.focused == 3 { + toggleLabel = lipgloss.NewStyle().Bold(true).Foreground(secondaryColor).Render("▸ Skip Permissions") + } + toggle := "[ ] no" + if m.skip { + toggle = "[✓] yes" + } + b.WriteString(toggleLabel + " " + detailValueStyle.Render(toggle) + "\n\n") + + if m.testing { + b.WriteString(statusWarnStyle.Render("⏳ Testing API connection...") + "\n\n") + } + + if m.err != "" { + b.WriteString(statusErrorStyle.Render("⚠ "+m.err) + "\n\n") + } + + b.WriteString(formHintStyle.Render("Tab: switch fields Space: toggle Enter: add Esc: cancel")) + + return b.String() +} diff --git a/internal/tui/profiles.go b/internal/tui/profiles.go new file mode 100644 index 0000000..34a4d7a --- /dev/null +++ b/internal/tui/profiles.go @@ -0,0 +1,107 @@ +package tui + +import ( + "fmt" + "strings" + + "codes/internal/config" + + "github.com/charmbracelet/bubbles/list" +) + +// profileItem implements list.Item for API profiles. +type profileItem struct { + cfg config.APIConfig + isDefault bool +} + +func (i profileItem) Title() string { + if i.isDefault { + return i.cfg.Name + " ★" + } + return i.cfg.Name +} + +func (i profileItem) Description() string { + if i.cfg.Status != "" { + return i.cfg.Status + } + return "unknown" +} + +func (i profileItem) FilterValue() string { return i.cfg.Name } + +// loadProfiles loads all API profiles and returns them as list items +// along with the default profile name. +func loadProfiles() ([]list.Item, string) { + cfg, err := config.LoadConfig() + if err != nil { + return nil, "" + } + + items := make([]list.Item, len(cfg.Profiles)) + for i, apiCfg := range cfg.Profiles { + items[i] = profileItem{ + cfg: apiCfg, + isDefault: apiCfg.Name == cfg.Default, + } + } + return items, cfg.Default +} + +// renderProfileDetail renders the right-side detail panel for a profile item. +func renderProfileDetail(item profileItem, width, height int) string { + var b strings.Builder + + // Name + b.WriteString(fmt.Sprintf(" %s %s\n", + detailLabelStyle.Render("Name:"), + detailValueStyle.Render(item.cfg.Name))) + + // Status with color indicator + status := item.cfg.Status + if status == "" { + status = "unknown" + } + var styledStatus string + switch status { + case "active": + styledStatus = statusOkStyle.Render(status + " ✓") + case "inactive": + styledStatus = statusErrorStyle.Render(status + " ✗") + default: + styledStatus = statusWarnStyle.Render(status + " ?") + } + b.WriteString(fmt.Sprintf(" %s %s\n", + detailLabelStyle.Render("Status:"), + styledStatus)) + + // Default indicator + defaultVal := "no" + if item.isDefault { + defaultVal = "★ yes" + } + b.WriteString(fmt.Sprintf(" %s %s\n", + detailLabelStyle.Render("Default:"), + detailValueStyle.Render(defaultVal))) + + // Environment variable count + envCount := len(item.cfg.Env) + b.WriteString(fmt.Sprintf(" %s %s\n", + detailLabelStyle.Render("Env vars:"), + detailValueStyle.Render(fmt.Sprintf("%d", envCount)))) + + // Skip permissions + skipVal := "no" + if item.cfg.SkipPermissions != nil && *item.cfg.SkipPermissions { + skipVal = "yes" + } + b.WriteString(fmt.Sprintf(" %s %s\n", + detailLabelStyle.Render("Skip:"), + detailValueStyle.Render(skipVal))) + + return detailBorderStyle. + Width(width - 4). + Height(height - 4). + Render(b.String()) +} diff --git a/internal/tui/projects.go b/internal/tui/projects.go new file mode 100644 index 0000000..70bfede --- /dev/null +++ b/internal/tui/projects.go @@ -0,0 +1,179 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + "codes/internal/config" + "codes/internal/session" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/lipgloss" +) + +// projectItem implements the list.Item interface for project entries. +type projectItem struct { + info config.ProjectInfo +} + +func (i projectItem) Title() string { return i.info.Name } +func (i projectItem) Description() string { return i.info.Path } +func (i projectItem) FilterValue() string { return i.info.Name } + +// loadProjects returns a sorted slice of list.Item from the configured projects. +func loadProjects() []list.Item { + projects, err := config.ListProjects() + if err != nil { + return nil + } + + names := make([]string, 0, len(projects)) + for name := range projects { + names = append(names, name) + } + sort.Strings(names) + + items := make([]list.Item, 0, len(names)) + for _, name := range names { + info := config.GetProjectInfo(name, projects[name]) + items = append(items, projectItem{info: info}) + } + + return items +} + +// renderProjectDetail renders the right-side detail panel for a project. +// When focused is true, sessions become selectable with a cursor at sessionCursor. +func renderProjectDetail(info config.ProjectInfo, width, height int, mgr *session.Manager, focused bool, sessionCursor int) string { + var b strings.Builder + + if !info.Exists { + b.WriteString(statusWarnStyle.Render(" Directory not found")) + b.WriteString("\n\n") + b.WriteString(fmt.Sprintf(" %s %s", + detailLabelStyle.Render("Path:"), + detailValueStyle.Render(info.Path))) + return detailBorderStyle. + Width(width - 4). + Height(height - 4). + Render(b.String()) + } + + // Sessions + if mgr != nil { + runningSessions := mgr.GetRunningByProject(info.Name) + + if len(runningSessions) > 0 { + b.WriteString(fmt.Sprintf(" %s %s\n\n", + detailLabelStyle.Render("Sessions:"), + statusOkStyle.Render(fmt.Sprintf("%d running", len(runningSessions))))) + + for i, s := range runningSessions { + prefix := " " + if focused && i == sessionCursor { + prefix = statusOkStyle.Render(" > ") + } + + labelStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")). + Background(primaryColor).Padding(0, 1) + if focused && i == sessionCursor { + labelStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")). + Background(secondaryColor).Padding(0, 1) + } + label := labelStyle.Render(s.ID) + b.WriteString(fmt.Sprintf("%s%s\n", prefix, label)) + b.WriteString(fmt.Sprintf("%s%s PID %d %s %s\n\n", + prefix, + statusOkStyle.Render("●"), + s.PID, + lipgloss.NewStyle().Foreground(mutedColor).Render("uptime"), + detailValueStyle.Render(s.Uptime().String()))) + } + + // "+ New Session" option + newIdx := len(runningSessions) + prefix := " " + if focused && sessionCursor == newIdx { + prefix = statusOkStyle.Render(" > ") + } + newStyle := lipgloss.NewStyle().Foreground(mutedColor) + if focused && sessionCursor == newIdx { + newStyle = lipgloss.NewStyle().Bold(true).Foreground(secondaryColor) + } + b.WriteString(prefix + newStyle.Render("+ New Session") + "\n") + } else { + b.WriteString(fmt.Sprintf(" %s %s\n", + detailLabelStyle.Render("Sessions:"), + lipgloss.NewStyle().Foreground(mutedColor).Render("No active sessions"))) + } + b.WriteString("\n") + } + + // Path + b.WriteString(fmt.Sprintf(" %s %s", + detailLabelStyle.Render("Path:"), + detailValueStyle.Render(info.Path))) + b.WriteString("\n") + + // Git branch + dirty indicator + if info.GitBranch != "" { + gitStatus := statusOkStyle.Render("✓ clean") + if info.GitDirty { + gitStatus = statusErrorStyle.Render("✗ dirty") + } + b.WriteString(fmt.Sprintf(" %s %s %s", + detailLabelStyle.Render("Git:"), + detailValueStyle.Render(info.GitBranch), + gitStatus)) + } else { + b.WriteString(fmt.Sprintf(" %s %s", + detailLabelStyle.Render("Git:"), + lipgloss.NewStyle().Foreground(mutedColor).Render("not a git repo"))) + } + b.WriteString("\n") + + // CLAUDE.md presence + claudeStatus := statusErrorStyle.Render("✗") + if info.HasClaudeMD { + claudeStatus = statusOkStyle.Render("CLAUDE.md ✓") + } + b.WriteString(fmt.Sprintf(" %s %s", + detailLabelStyle.Render("Claude:"), + claudeStatus)) + b.WriteString("\n") + + // Recent branches + if len(info.RecentBranches) > 0 { + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" %s", detailLabelStyle.Render("Branches:"))) + b.WriteString("\n") + for _, branch := range info.RecentBranches { + b.WriteString(fmt.Sprintf(" %s", + detailValueStyle.Render(branch))) + b.WriteString("\n") + } + } + + // Keybinding hints + b.WriteString("\n") + if focused { + b.WriteString(formHintStyle.Render(" ↑↓: select Enter: open k: kill ←: back")) + } else { + b.WriteString(formHintStyle.Render(" →: select session Enter: new k: kill")) + } + + // Use highlighted border when focused + borderStyle := detailBorderStyle + if focused { + borderStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(secondaryColor). + Padding(1, 2) + } + + return borderStyle. + Width(width - 4). + Height(height - 4). + Render(b.String()) +} diff --git a/internal/tui/settings.go b/internal/tui/settings.go new file mode 100644 index 0000000..fe0d846 --- /dev/null +++ b/internal/tui/settings.go @@ -0,0 +1,152 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "codes/internal/config" +) + +type settingItem struct { + label string // display label + key string // internal key + value string // current value + options []string // available options (nil = read-only) +} + +type settingsModel struct { + items []settingItem + cursor int +} + +// settingChangedMsg is sent after a setting value is changed. +type settingChangedMsg struct{} + +func newSettingsModel(cfg *config.Config) settingsModel { + terminal := "terminal" + behavior := "current" + skip := "off" + configFile := config.ConfigPath + + if cfg != nil { + if cfg.Terminal != "" { + terminal = cfg.Terminal + } + if cfg.DefaultBehavior != "" { + behavior = cfg.DefaultBehavior + } + if cfg.SkipPermissions { + skip = "on" + } + } + + return settingsModel{ + items: []settingItem{ + { + label: "Terminal", + key: "terminal", + value: terminal, + options: config.TerminalOptions(), + }, + { + label: "Default Behavior", + key: "defaultBehavior", + value: behavior, + options: []string{"current", "last", "home"}, + }, + { + label: "Skip Permissions", + key: "skipPermissions", + value: skip, + options: []string{"off", "on"}, + }, + { + label: "Config File", + key: "configFile", + value: configFile, + options: nil, // read-only + }, + }, + } +} + +func (s settingsModel) View(width, height int) string { + var b strings.Builder + + title := detailLabelStyle.Render("Settings") + b.WriteString(" " + title + "\n\n") + + for i, item := range s.items { + cursor := " " + labelStyle := formLabelStyle + if i == s.cursor { + cursor = "▸ " + labelStyle = detailLabelStyle + } + + label := labelStyle.Render(item.label) + + var valueStr string + if item.options != nil { + // Show current value with cycle hint + valueStr = detailValueStyle.Render(item.value) + if i == s.cursor { + valueStr = statusOkStyle.Render(item.value) + } + } else { + // Read-only + valueStr = lipgloss.NewStyle().Foreground(mutedColor).Render(item.value) + } + + b.WriteString(fmt.Sprintf("%s%-20s %s\n", cursor, label, valueStr)) + + // Show description for selected item + if i == s.cursor { + desc := settingDescription(item.key, item.value) + if desc != "" { + b.WriteString(fmt.Sprintf(" %-20s %s\n", "", formHintStyle.Render(desc))) + } + } + b.WriteString("\n") + } + + return detailBorderStyle. + Width(width - 4). + Height(height - 4). + Render(b.String()) +} + +func settingDescription(key, value string) string { + switch key { + case "terminal": + switch value { + case "terminal": + return "macOS Terminal.app" + case "iterm": + return "iTerm2" + case "warp": + return "Warp terminal" + default: + return "Custom: " + value + } + case "defaultBehavior": + switch value { + case "current": + return "Start in current working directory" + case "last": + return "Start in last used directory" + case "home": + return "Start in home directory" + } + case "skipPermissions": + if value == "on" { + return "Claude runs with --dangerously-skip-permissions" + } + return "Claude runs with normal permission checks" + case "configFile": + return "Read-only" + } + return "" +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..56ba4cb --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,74 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +var ( + // Colors + primaryColor = lipgloss.Color("#7C3AED") // purple + secondaryColor = lipgloss.Color("#10B981") // green + mutedColor = lipgloss.Color("#6B7280") // gray + dangerColor = lipgloss.Color("#EF4444") // red + warnColor = lipgloss.Color("#F59E0B") // yellow + + // App frame + appStyle = lipgloss.NewStyle().Padding(1, 2) + + // Title bar + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(primaryColor). + Padding(0, 1) + + // Active tab + activeTabStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(primaryColor). + Underline(true) + + // Inactive tab + inactiveTabStyle = lipgloss.NewStyle(). + Foreground(mutedColor) + + // Detail panel styles + detailBorderStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(primaryColor). + Padding(1, 2) + + detailLabelStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(primaryColor) + + detailValueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#E5E7EB")) + + // Status indicators + statusOkStyle = lipgloss.NewStyle(). + Foreground(secondaryColor) + + statusWarnStyle = lipgloss.NewStyle(). + Foreground(warnColor) + + statusErrorStyle = lipgloss.NewStyle(). + Foreground(dangerColor) + + // Help bar + helpStyle = lipgloss.NewStyle(). + Foreground(mutedColor). + Padding(1, 0, 0, 0) + + // Form styles + formLabelStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(primaryColor) + + formInputStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(primaryColor). + Padding(0, 1) + + formHintStyle = lipgloss.NewStyle(). + Foreground(mutedColor). + Italic(true) +)