diff --git a/.gitignore b/.gitignore index cb734d9..6615d5e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ coverage.* /CLAUDE.md /.claude/ /TechnicalDocument.md +/start_claude.sh + +opencode_prompt.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b433e3..fcd1bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Multi-agent workspace disambiguation.** Opening a second workspace at a path + that is already registered no longer overwrites the existing entry when a + different agent is selected. Crabby generates a unique workspace name from the + path and the agent binary (e.g. `payments-opencode`), so `claude` and + `opencode` workspaces can coexist at the same directory and are independently + listed on the dashboard. +- **Per-task agent selection.** Pressing **t** to create a new task now includes + an agent-selection step in the creation wizard. The chosen agent is persisted + in `projects.json` (`agent_command` field) and used every time that task is + opened, independently of the workspace's default agent. + ## [0.9.0] - 2026-07-15 ### Added -- **Mission Control** (F12): a live overview of every Claude task at once — a +- **Mission Control** (F12): a live overview of every AI Agent task at once — a grid of cards, one per task, each showing its status, branch, uptime, model and tokens, and a short live preview of the session's screen. Previews come from - `tmux capture-pane`, stripped of box art and Claude's status chrome so the card + `tmux capture-pane`, stripped of box art and the Agent's status chrome so the card shows recent output and the current prompt. Captures are cached and only refreshed when a session produces new output, so it scales to dozens of tasks. Arrow/Tab keys move between cards; Enter attaches and leaving returns to Mission @@ -47,9 +60,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 persistent, multi-panel home screen and every day-to-day action happens inside it — the shell is no longer part of the workflow. The dashboard is three columns (**Workspaces | Tasks | Details**) with keyboard focus moving between - panes (Tab / arrows); Enter opens the selected task in Claude. -- Attaching to a Claude session now runs through the app (via `tea.ExecProcess`), - so the screen is released for Claude and the dashboard restored on return — + panes (Tab / arrows); Enter opens the selected task in Agent. +- Attaching to a AI Agent session now runs through the app (via `tea.ExecProcess`), + so the screen is released for Agent and the dashboard restored on return — without ever leaving Crabby. The prefix-free return key (F12) is shown on screen and in Help. @@ -82,8 +95,8 @@ storage, and pack format are unchanged. ### Fixed -- **Mouse wheel scrolled Claude's prompt history instead of the pane.** With - tmux's mouse mode off (the default), the wheel was forwarded to Claude Code as +- **Mouse wheel scrolled the Agent's prompt history instead of the pane.** With + tmux's mouse mode off (the default), the wheel was forwarded to AI Agent as arrow keys, cycling through old prompts rather than scrolling up through the response. Crabby now enables mouse mode on its tmux socket so the wheel scrolls the pane's scrollback. (Hold Shift while dragging for the terminal's native @@ -114,7 +127,7 @@ storage, and pack format are unchanged. ### Added - **Multiple tasks per workspace.** A workspace can now hold several independent - Claude sessions ("tasks") that share the same project directory — work on a + AI Agent sessions ("tasks") that share the same project directory — work on a refactor, tests, and docs in parallel without cloning the repo. New commands: - `crabby task create ` — start a new task and open it. - `crabby task list [workspace]` — list a workspace's tasks and their state. @@ -142,7 +155,7 @@ storage, and pack format are unchanged. required. - With several tasks sharing one directory, transcript-derived detail (model, tokens, fine activity) is shown at the workspace level; per-task rows show the - reliable tmux state, since Claude's transcripts can't be attributed to a + reliable tmux state, since the Agent's transcripts can't be attributed to a specific tmux session. ## [0.6.0] - 2026-07-13 @@ -152,7 +165,7 @@ storage, and pack format are unchanged. - **Workspace insights on the dashboard.** Each workspace now shows what Claude is doing (Thinking / Editing files / Reading files / Running command / Responding / Waiting for input / Idle), the model in use, tokens spent this - session, uptime, and how long ago Claude last acted — all read from Claude + session, uptime, and how long ago Agent last acted — all read from Claude Code's own session transcripts under `~/.claude/projects/`, not from terminal scraping. - **Global usage summary** below the list: workspace counts by state, how many @@ -196,7 +209,7 @@ storage, and pack format are unchanged. ### Added -- **`crabby import`.** Adopt existing Claude Code repositories in bulk. It scans +- **`crabby import`.** Adopt existing AI Agent repositories in bulk. It scans a directory tree for git repositories that already contain a `CLAUDE.md` and presents them in an interactive selector (space to choose, Enter to import) — so a folder full of repos becomes Crabby workspaces in seconds. Already-imported @@ -301,7 +314,7 @@ No functional changes. - **Crab-army header** on the home screen (Lip Gloss), giving Crabby a playful, recognizable identity. - **Stop a session from the home screen** with `x` (confirmed with `y/n`) — no - need to attach or exit Claude. + need to attach or exit Agent. - **New project** from the home screen with `n`, which initializes the current directory (with pack selection). @@ -322,14 +335,14 @@ No functional changes. - `crabby` with no arguments now opens the home screen (the TUI). `crabby ps` remains as an alias. -- The home screen is a loop: pick a project → work in Claude → leave → you land +- The home screen is a loop: pick a project → work in Agent → leave → you land back on the list automatically, ready to pick another. No shell commands, no session names to remember. - Selecting a project starts its session if it isn't running yet, so opening a project is always a single keypress. - The TUI shows more, minimally: project name, session state (with clearer colors), path, git branch, and time since last activity. -- README reframed around "a workspace manager for Claude Code"; tmux is +- README reframed around "a workspace manager for AI Agent"; tmux is described only as an implementation detail. ### Added @@ -368,11 +381,11 @@ No functional changes. ### Added -- `crabby init` — prepare a project for Claude Code (`.claude/crabby.yaml`, `CLAUDE.md`, registry entry). -- `crabby start` — create the tmux session, launch Claude, and attach (or attach if it already exists). +- `crabby init` — prepare a project for AI Agent (`.claude/crabby.yaml`, `CLAUDE.md`, registry entry). +- `crabby start` — create the tmux session, launch Agent, and attach (or attach if it already exists). - `crabby ps` — Bubble Tea TUI listing every project and its session state, with Enter-to-attach. -- `crabby attach` — attach directly to a project's Claude session. -- `crabby doctor` — verify WSL, Ubuntu, tmux, Claude, and crabby are present. +- `crabby attach` — attach directly to a project's AI Agent session. +- `crabby doctor` — verify WSL, Ubuntu, tmux, Agent, and crabby are present. - `crabby version` — print the Crabby version. - Windows wrapper (`crabby.exe`) that forwards commands into WSL. - One-command installers for Windows (`install.ps1`) and Linux/WSL (`install.sh`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca9c63d..b9df150 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ Thanks for your interest in Crabby! It is intentionally small — please keep changes focused and in line with its philosophy: -> Make working with multiple Claude Code sessions effortless. +> Make working with multiple AI Agent sessions effortless. Crabby is **not** a Git client, a tmux replacement, a project manager, an IDE, or an AI orchestrator. Features outside that scope will not be accepted. @@ -11,7 +11,7 @@ or an AI orchestrator. Features outside that scope will not be accepted. ## Requirements - [Go](https://go.dev) 1.23+ -- tmux and [Claude Code](https://claude.com/claude-code) (to run it end-to-end) +- tmux and [AI Agent](https://claude.com/claude-code) (to run it end-to-end) - WSL2 + Ubuntu (the only supported runtime) ## Development diff --git a/README.md b/README.md index d8eff1e..428f220 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,23 @@ image -A workspace manager for Claude Code — _by [marioolf](https://github.com/marioolf) · [github.com/marioolf/crabby](https://github.com/marioolf/crabby)_ +A workspace manager for AI Agent — _by [marioolf](https://github.com/marioolf) · [github.com/marioolf/crabby](https://github.com/marioolf/crabby)_ -Run `crabby`, pick a project, and you're in Claude. Leave the session with a +Run `crabby`, pick a project, and you're in Agent. Leave the session with a single key and you're back on the project list, ready to jump into the next one. -Crabby is the home screen for all your Claude Code work. +Crabby is the home screen for all your AI Agent work. ``` crabby ← your projects │ Enter ▼ -Claude (Project A) ← work +Agent (Project A) ← work │ F12 ▼ crabby ← back on the list │ Enter ▼ -Claude (Project B) ← switch, instantly +Agent (Project B) ← switch, instantly ``` No shell commands between sessions. No session names to remember. You never @@ -25,9 +25,9 @@ leave Crabby. ## Philosophy -> Make working with multiple Claude Code sessions effortless. +> Make working with multiple AI Agent sessions effortless. -Crabby is small and opinionated. It assumes **WSL + Claude Code** and needs no +Crabby is small and opinionated. It assumes **WSL + AI Agent** and needs no configuration. Under the hood it uses tmux to keep your sessions alive, but you never have to think about it — Crabby owns the whole experience and tmux stays out of sight. @@ -52,7 +52,7 @@ curl -fsSL https://raw.githubusercontent.com/marioolf/crabby/main/install.sh | b ``` This downloads the right binary for your architecture into `~/.local/bin` and -checks tmux and Claude Code. +checks tmux and AI Agent. Then confirm everything is ready: @@ -66,7 +66,7 @@ crabby doctor ## Everyday use Crabby is a terminal application. Open it once and stay inside it — creating -workspaces and tasks, jumping into Claude, and managing packs all happen in the +workspaces and tasks, jumping into Agent, and managing packs all happen in the interface. There are no commands to remember. ```bash @@ -108,24 +108,29 @@ and [Workspace insights](#workspace-insights) for where the Details come from. - **↑ ↓** move within the focused column; **Tab / Shift+Tab** (or **← →**) switch between Workspaces and Tasks. -- **Enter** opens the selected task in Claude. If it wasn't running, Crabby +- **Enter** opens the selected task in Agent. If it wasn't running, Crabby starts it for you. - **F12** toggles [Mission Control](#mission-control), the live overview of every - Claude task. Inside a Claude session, **F12** brings you straight back here — + AI Agent task. Inside a AI Agent session, **F12** brings you straight back here — no tmux shortcuts to learn; the session keeps running in the background. - **n** creates a new workspace from any folder (see [Packs](#packs)) — the path field completes with **Tab** and lists matching sub-directories as you type, and - **Ctrl+O** opens the native Windows folder picker. -- **i** [imports existing Claude workspaces](#adopting-existing-projects) — it + **Ctrl+O** opens the native Windows folder picker. You are also asked **which AI + agent** (e.g. `claude`, `opencode`, `agy`) to associate with the workspace; + this keeps separate workspaces at the same path from overwriting each other (see + [Multi-agent workspaces](#multi-agent-workspaces)). +- **i** [imports existing Agent workspaces](#adopting-existing-projects) — it scans your machine automatically. -- **t** starts a new task in the selected workspace. +- **t** starts a new task in the selected workspace. You will be asked to choose + the **agent** for that task; it can differ from the workspace's default agent + (see [Per-task agent selection](#per-task-agent-selection)). - **o** opens the selected workspace's folder in the file manager (Windows Explorer on WSL), so you can drop files straight into it. - **e** relinks a workspace whose folder you've moved or renamed: Crabby flags it as `⚠ missing`, and **e** points it at the new folder (updating its name and path, keeping its tasks). Crabby can't guess where a folder went, so this is how you tell it. -- **x** stops the highlighted task (asks first) — no need to exit Claude. +- **x** stops the highlighted task (asks first) — no need to exit Agent. - **d** removes the highlighted task, or the whole workspace (asks first). Your files are kept; only Crabby forgets it. - **p** manages packs, **s** opens Settings, **?** shows the full key list, and @@ -140,16 +145,16 @@ Crabby has three simple concepts: - **Workspace** — a project directory with a `CLAUDE.md`. This is the source of truth; it's what **n** (new) and **i** (import) register. -- **Task** — one Claude Code session working inside a workspace. A workspace can +- **Task** — one AI Agent session working inside a workspace. A workspace can have several, each an independent session **sharing the same directory**. -- **Claude session** — the actual running Claude, one per task, kept alive in +- **AI Agent session** — the actual running Agent, one per task, kept alive in its own tmux session (named automatically, e.g. `crabby_payments_tests`). One workspace, multiple tasks. This lets you run parallel efforts in the same repository without cloning it — all from the home screen: - Select the workspace and press **t** to start a new task (say `refactor`); it - opens straight away in its own Claude. + opens straight away in its own Agent. - Press **t** again for a second, independent task (say `tests`) in the same repo. - Both appear in the **Tasks** column; **Tab** into it, pick one, **Enter** to open, **d** to remove it (the workspace stays). @@ -157,11 +162,33 @@ repository without cloning it — all from the home screen: Every workspace starts with one default task (`main`), so a workspace with a single task behaves exactly like a single session. Tasks are independent — Crabby does not coordinate them, share memory between them, or manage git branches; each -is just its own Claude. +is just its own Agent. + +## Multi-agent workspaces + +Crabby supports running **multiple AI agents side by side**, even on the same +project directory. When you open a second workspace pointing at a folder that is +already registered, Crabby checks whether the agent differs: + +- **Same agent** → the existing workspace is reused (no duplication). +- **Different agent** → a new, independent workspace is created with a unique + name derived from the path and the agent binary (e.g. `payments-opencode`). + +This means you can have, for example, a `claude` workspace and an `opencode` +workspace both targeting `/repos/payments` and switch between them from the +dashboard without either one overwriting the other. + +## Per-task agent selection + +When you press **t** to create a task, Crabby asks you which agent should run it. +The selected agent is saved with the task in `projects.json` and used whenever +that task is opened, independently of the workspace's default agent. This lets +you run a `claude` task and an `opencode` task inside the same workspace at the +same time, each in its own tmux session. ## Workspace insights -Crabby reads Claude Code's own session transcripts to show, at a glance, what is +Crabby reads AI Agent's own session transcripts to show, at a glance, what is happening across every workspace — without attaching to a single one. Each workspace shows only the information that is actually available: @@ -183,11 +210,11 @@ And a global summary sits below the list: | Metric | Source | Notes | | --- | --- | --- | | State (running / waiting / stopped) | tmux | Reliable. Running = attached, waiting = alive, stopped = no session. | -| Activity (Thinking / Editing files / Reading files / Running command / Responding / Waiting for input / Idle) | Claude transcript tail + tmux "working" signal | Best-effort. Derived from the last recorded step, so it can lag the live terminal by a moment. Only the fine label; the coarse state is always reliable. | -| Model | Claude transcript | Reliable — the model of the latest turn. | -| Tokens (per workspace) | Claude transcript | Reliable. Input + output for the **current session** (cache tokens excluded). | -| Tokens today (global summary) | Claude transcript | Reliable. Input + output across the workspace's transcripts **dated today**. | -| Last activity | Claude transcript | Reliable — timestamp of the last recorded turn. | +| Activity (Thinking / Editing files / Reading files / Running command / Responding / Waiting for input / Idle) | Agent transcript tail + tmux "working" signal | Best-effort. Derived from the last recorded step, so it can lag the live terminal by a moment. Only the fine label; the coarse state is always reliable. | +| Model | Agent transcript | Reliable — the model of the latest turn. | +| Tokens (per workspace) | Agent transcript | Reliable. Input + output for the **current session** (cache tokens excluded). | +| Tokens today (global summary) | Agent transcript | Reliable. Input + output across the workspace's transcripts **dated today**. | +| Last activity | Agent transcript | Reliable — timestamp of the last recorded turn. | | Uptime | tmux | Reliable — how long the session has been alive. | | Branch | git (read directly) | Reliable when the folder is a git repository. | @@ -196,14 +223,14 @@ completion times, and it omits any field it cannot read reliably rather than showing a placeholder. Transcripts are read incrementally — only the bytes added since the last refresh — so the dashboard stays fast with many workspaces. -Metrics that depend on Claude Code (activity, model, tokens, last activity) -require Claude's transcript files under `~/.claude/projects/`. If Claude Code has +Metrics that depend on AI Agent (activity, model, tokens, last activity) +require the Agent's transcript files under `~/.claude/projects/`. If AI Agent has never run in a workspace, those fields are simply absent and the workspace still shows its tmux state, branch, and uptime. ## Adopting existing projects -If you already use Claude Code, you probably have repositories with a `CLAUDE.md` +If you already use AI Agent, you probably have repositories with a `CLAUDE.md` in them. You don't need to recreate them — bring them in as they are, from inside Crabby. @@ -217,7 +244,7 @@ finds every folder with a `CLAUDE.md` (or `claude.md`), and lists them: Import workspaces - Found 4 Claude workspaces · Selected: 2 + Found 4 Agent workspaces · Selected: 2 ▌ [✔] payments ✔ already imported · /repos/payments @@ -253,8 +280,8 @@ importable. ## Mission Control -Press **F12** to open **Mission Control** — a live overview of *every* Claude task -at once, so you can see what all your Claude workers are doing without attaching to +Press **F12** to open **Mission Control** — a live overview of *every* AI Agent task +at once, so you can see what all your Agent workers are doing without attaching to each one. **F12** again returns to the dashboard. ``` @@ -289,7 +316,7 @@ Each card is one task. It shows the status Crabby can read reliably — **Workin transcript is available, and a short **live preview** of the session's screen. The preview comes from `tmux capture-pane`, reduced to the last few lines that -actually carry content: Crabby strips the box art, blank lines and Claude's +actually carry content: Crabby strips the box art, blank lines and the Agent's persistent status bar, so the card shows recent output and the current prompt rather than the whole terminal. Mission Control is an **observation surface**, not a terminal emulator — it never lets you type into multiple sessions at once. @@ -308,7 +335,7 @@ command line is intentionally tiny. | Command | What it does | | --- | --- | | `crabby` | Open the application — the home screen and everything from there. | -| `crabby doctor` | Check that WSL, Ubuntu, tmux, Claude, and crabby are present. | +| `crabby doctor` | Check that WSL, Ubuntu, tmux, Agent, and crabby are present. | | `crabby version` | Print the installed version. | Creating and importing workspaces, managing tasks, and managing packs all moved @@ -384,12 +411,12 @@ copy one of the bundled examples in [`examples/`](examples/) (`simple-pack`, | State | Meaning | | --- | --- | | 🟢 `Running` | You're attached — this is the session you're in. | -| 🟡 `Waiting` | Claude is alive in the background; press Enter to jump back in. | +| 🟡 `Waiting` | Agent is alive in the background; press Enter to jump back in. | | ⚪ `Stopped` | Nothing running; press Enter to start fresh. | A live session that is actively producing output shows what it's doing (e.g. `Working`, `Thinking…`, `Editing files`), so you can see at a glance which of -your Claude workers are busy. See [Workspace insights](#workspace-insights). +your Agent workers are busy. See [Workspace insights](#workspace-insights). ## Notifications @@ -399,7 +426,7 @@ top, and a session that has just started producing output rings a short terminal bell. **Limitation, stated honestly:** Crabby cannot pop a desktop notification *while -you are inside a Claude session*. When you open a session, Crabby hands the +you are inside a AI Agent session*. When you open a session, Crabby hands the whole terminal to it and waits — it is not running in the background, so it has nothing to notify *from*. Reliable cross-session desktop alerts would require a background daemon, which this project intentionally avoids. The practical, @@ -413,12 +440,14 @@ Crabby needs no configuration. If you want to change something, edit `~/.config/crabby/config.yaml`: ```yaml -claude_command: claude # how to launch Claude Code +claude_command: claude # default agent command (overridable per workspace / task) tmux_binary: tmux # tmux binary to use detach_key: F12 # key that returns you to Crabby ``` -Projects are recorded in `~/.local/share/crabby/projects.json`. +Projects are recorded in `~/.local/share/crabby/projects.json`. Each entry +includes the agent command used for that workspace and for each individual task, +so the right agent is launched automatically on `Enter`. If your keyboard sends F12 somewhere else, set `detach_key` to another [tmux key name](https://man.openbsd.org/tmux#KEY_BINDINGS) such as `C-g`. @@ -433,7 +462,7 @@ internal/ cli/ # cobra: `crabby`, `version`, `doctor` initcmd/ # workspace initialization (+ pack application) importcmd/ # workspace discovery for import - insights/ # reads Claude transcripts for dashboard metrics + insights/ # reads Agent transcripts for dashboard metrics pack/ # local project packs (list + create/duplicate/delete) project/ # projects.json registry session/ # project <-> session mapping + state diff --git a/VERSION b/VERSION index ac39a10..f374f66 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0 +0.9.1 diff --git a/go.mod b/go.mod index 8042e63..831871b 100644 --- a/go.mod +++ b/go.mod @@ -1,29 +1,35 @@ module github.com/marioolf/crabby -go 1.23 +go 1.24.0 require ( - github.com/charmbracelet/bubbletea v1.2.4 - github.com/charmbracelet/lipgloss v1.0.0 + github.com/charmbracelet/bubbletea v1.3.5 + github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.8.1 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/aymanbagabas/go-udiff v0.3.1 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect + github.com/charmbracelet/x/exp/teatest v0.0.0-20260803091719-3755ebad01b1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.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.15 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/sync v0.9.0 // indirect - golang.org/x/sys v0.27.0 // indirect - golang.org/x/text v0.3.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 875e7be..e5a3f86 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,27 @@ 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/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= +github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= +github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +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.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/teatest v0.0.0-20260803091719-3755ebad01b1 h1:wHQXg8yTV8dugUh26pT6wXpqsAMQGL0HKh4zHGMAj/Q= +github.com/charmbracelet/x/exp/teatest v0.0.0-20260803091719-3755ebad01b1/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -21,12 +37,16 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -35,13 +55,21 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/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= golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 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/install.ps1 b/install.ps1 index 451ab8e..4fc9b39 100644 --- a/install.ps1 +++ b/install.ps1 @@ -60,9 +60,6 @@ function Get-CrabbyFile { } # Fallback: Invoke-WebRequest, made as browser-like as possible. - try { - [System.Net.WebRequest]::DefaultWebProxy.Credentials = [System.Net.CredentialCache]::DefaultCredentials - } catch {} Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing ` -MaximumRedirection 5 -ErrorAction Stop ` diff --git a/install.sh b/install.sh index 63c46c4..37534f0 100644 --- a/install.sh +++ b/install.sh @@ -66,9 +66,9 @@ main() { fi if command -v claude >/dev/null 2>&1; then - ok "Claude Code found" + ok "AI Agent found" else - warn "Claude Code not found — see https://claude.com/claude-code" + warn "AI Agent not found — see https://claude.com/claude-code" fi echo diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 610d283..6e0d468 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -31,11 +31,11 @@ func Execute() int { func newRootCmd() *cobra.Command { root := &cobra.Command{ Use: "crabby", - Short: "A workspace manager for Claude Code", + Short: "A workspace manager for AI Agent", Long: tui.Banner() + "\n\n" + - "A workspace manager for Claude Code.\n" + + "A workspace manager for AI Agent.\n" + "Run it with no arguments to open the app: create workspaces and tasks,\n" + - "jump into Claude, and manage packs — all without leaving the terminal.\n\n" + + "jump into Agent, and manage packs — all without leaving the terminal.\n\n" + version.Repo, SilenceUsage: true, SilenceErrors: true, @@ -69,7 +69,7 @@ func home() error { return errTmuxMissing(cfg.TmuxBinary) } // One collector for the whole session so its incremental transcript cache - // survives returning from a Claude session. + // survives returning from a AI Agent session. return tui.Run(cfg, t, insights.New()) } diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index cf60372..828d5d3 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -23,7 +23,7 @@ func Run(cfg config.Config) []Check { checkWSL(), checkUbuntu(), checkBinary("tmux installed", cfg.TmuxBinary), - checkBinary("Claude installed", cfg.ClaudeCommand), + checkBinary("Agent installed", cfg.ClaudeCommand), checkCrabby(), } } diff --git a/internal/importcmd/discover_extra_test.go b/internal/importcmd/discover_extra_test.go index b833e5b..65842fa 100644 --- a/internal/importcmd/discover_extra_test.go +++ b/internal/importcmd/discover_extra_test.go @@ -21,14 +21,14 @@ func TestDiscoverMatchesLowercaseClaude(t *testing.T) { } mk("upper", "CLAUDE.md") mk("lower", "claude.md") - mk("title", "Claude.md") + mk("title", "Agent.md") found, err := Discover(root) if err != nil { t.Fatalf("Discover: %v", err) } if len(found) != 3 { - t.Fatalf("got %d workspaces, want 3 (CLAUDE.md/claude.md/Claude.md)", len(found)) + t.Fatalf("got %d workspaces, want 3 (CLAUDE.md/claude.md/Agent.md)", len(found)) } } diff --git a/internal/importcmd/importcmd.go b/internal/importcmd/importcmd.go index 066b680..204703f 100644 --- a/internal/importcmd/importcmd.go +++ b/internal/importcmd/importcmd.go @@ -1,4 +1,4 @@ -// Package importcmd discovers existing Claude Code projects so they can be +// Package importcmd discovers existing AI Agent projects so they can be // adopted into Crabby without re-initializing them. // // A folder qualifies simply by containing a CLAUDE.md (or claude.md) — a git @@ -23,7 +23,7 @@ import ( "github.com/marioolf/crabby/internal/project" ) -// Workspace is a discovered Claude Code project, ready to be imported. +// Workspace is a discovered AI Agent project, ready to be imported. type Workspace struct { Name string // directory base name Path string // absolute path @@ -51,9 +51,9 @@ var ignoredDirs = map[string]bool{ ".gradle": true, } -// contextFiles are the names that mark a Claude workspace, matched +// contextFiles are the names that mark a Agent workspace, matched // case-sensitively against a small set rather than statting the whole directory. -var contextFiles = []string{"CLAUDE.md", "claude.md", "Claude.md"} +var contextFiles = []string{"CLAUDE.md", "claude.md", "Agent.md"} // Discover walks root and returns every folder containing a context file found // beneath it, sorted by name. Already-registered projects are flagged rather @@ -160,6 +160,19 @@ func skipWinUser(name string) bool { return false } +// validateWorkspaceName ensures the workspace name contains only safe characters. +func validateWorkspaceName(name string) bool { + if name == "" { + return false + } + for _, r := range name { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-') { + return false + } + } + return true +} + // walkRoot walks one root, returning the workspaces beneath it. maxDepth < 0 // means no limit; otherwise directories deeper than maxDepth below root are not // descended into. @@ -175,7 +188,7 @@ func walkRoot(root string, maxDepth int, registered map[string]bool) []Workspace // Prune dependency folders and hidden directories (never the root the // caller named). Skipping dot-directories keeps config and cache trees — // ~/.claude, ~/.config, ~/.cache and the like — out of the results, which - // otherwise surface Crabby's own packs and the global Claude config. + // otherwise surface Crabby's own packs and the global Agent config. if path != root && (ignoredDirs[d.Name()] || strings.HasPrefix(d.Name(), ".")) { return fs.SkipDir } @@ -188,10 +201,21 @@ func walkRoot(root string, maxDepth int, registered map[string]bool) []Workspace // A context file marks a project. Record it (with its git branch if it // happens to be a repo) and stop descending, so nested context files // deeper in the project aren't imported as separate workspaces. + name := filepath.Base(path) + if !validateWorkspaceName(name) { + return fs.SkipDir + } + + var branch string + func() { + defer func() { recover() }() + branch = project.Project{Path: path}.Branch() + }() + found = append(found, Workspace{ - Name: filepath.Base(path), + Name: name, Path: path, - Branch: project.Project{Path: path}.Branch(), + Branch: branch, Imported: registered[path], }) return fs.SkipDir diff --git a/internal/initcmd/initcmd.go b/internal/initcmd/initcmd.go index adf3bce..8fce5fb 100644 --- a/internal/initcmd/initcmd.go +++ b/internal/initcmd/initcmd.go @@ -1,11 +1,13 @@ // Package initcmd implements `crabby init`: preparing a project so it is -// immediately ready to work with Claude Code, optionally from a reusable pack. +// immediately ready to work with AI Agent, optionally from a reusable pack. package initcmd import ( "fmt" "os" "path/filepath" + "strings" + "syscall" "github.com/marioolf/crabby/internal/pack" "github.com/marioolf/crabby/internal/project" @@ -41,7 +43,7 @@ type Result struct { // copied in (never overwriting existing ones); otherwise a minimal default // CLAUDE.md is created. Either way, Crabby's own .claude/crabby.yaml is written // and the project is registered. -func Init(dir string, p *pack.Pack) (Result, error) { +func Init(dir string, p *pack.Pack, agentCmd string) (Result, error) { abs, err := filepath.Abs(dir) if err != nil { return Result{}, err @@ -55,13 +57,22 @@ func Init(dir string, p *pack.Pack) (Result, error) { return Result{}, fmt.Errorf("%q is not a directory", dir) } - name := filepath.Base(abs) + baseName := filepath.Base(abs) + name := uniqueWorkspaceName(baseName, abs, agentCmd) + if err := validateWorkspaceName(name); err != nil { + return Result{}, err + } proj := project.Project{ Name: name, Path: abs, Tasks: []project.Task{ - {Name: project.DefaultTask, Session: session.Name(name)}, + { + Name: project.DefaultTask, + Session: session.Name(name), + AgentCommand: agentCmd, + }, }, + AgentCommand: agentCmd, } res := Result{Project: proj} @@ -69,6 +80,11 @@ func Init(dir string, p *pack.Pack) (Result, error) { if err := os.MkdirAll(claudeDir, 0o755); err != nil { return res, err } + if info, err := os.Lstat(claudeDir); err != nil { + return res, err + } else if info.Mode()&os.ModeSymlink != 0 { + return res, fmt.Errorf(".claude is a symlink") + } if p != nil { copied, skipped, err := pack.Apply(*p, abs) @@ -90,8 +106,21 @@ func Init(dir string, p *pack.Pack) (Result, error) { // Crabby owns .claude/crabby.yaml (project metadata for the registry), so it // is always written with the correct values regardless of the pack. - meta := fmt.Sprintf("name: %s\npath: %s\nsession: %s\n", proj.Name, proj.Path, proj.Tasks[0].Session) - if err := os.WriteFile(filepath.Join(claudeDir, "crabby.yaml"), []byte(meta), 0o644); err != nil { + meta := fmt.Sprintf("name: %s\npath: %s\nsession: %s\nagent_command: %s\n", proj.Name, proj.Path, proj.Tasks[0].Session, proj.AgentCommand) + crabbyPath := filepath.Join(claudeDir, "crabby.yaml") + if info, err := os.Lstat(crabbyPath); err == nil && info.Mode()&os.ModeSymlink != 0 { + return res, fmt.Errorf("crabby.yaml is a symlink") + } + + f, err := openNoFollow(crabbyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return res, err + } + if _, err := f.Write([]byte(meta)); err != nil { + f.Close() + return res, err + } + if err := f.Close(); err != nil { return res, err } @@ -103,13 +132,121 @@ func Init(dir string, p *pack.Pack) (Result, error) { // writeIfMissing writes content to path only if it does not already exist. func writeIfMissing(path, content string) (bool, error) { - if _, err := os.Stat(path); err == nil { - return false, nil + if info, err := os.Lstat(path); err == nil { + if info.Mode()&os.ModeSymlink != 0 { + return false, fmt.Errorf("%q is a symlink", path) + } + return false, nil // File exists } else if !os.IsNotExist(err) { return false, err } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + + f, err := openNoFollow(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return false, err + } + if _, err := f.Write([]byte(content)); err != nil { + f.Close() + return false, err + } + if err := f.Close(); err != nil { return false, err } return true, nil } + +// openNoFollow opens the final path component relative to a directory descriptor. +// This keeps a checked directory from being replaced by a symlink before the +// file is opened. +func openNoFollow(path string, flags int, perm os.FileMode) (*os.File, error) { + parent := filepath.Dir(path) + base := filepath.Base(path) + dirFD, err := syscall.Open(parent, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0) + if err != nil { + return nil, err + } + defer syscall.Close(dirFD) + + fd, err := syscall.Openat(dirFD, base, flags|syscall.O_NOFOLLOW, uint32(perm.Perm())) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(fd), path), nil +} + +// validateWorkspaceName ensures the workspace name contains only safe characters. +func validateWorkspaceName(name string) error { + if name == "" { + return fmt.Errorf("name cannot be empty") + } + for _, r := range name { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-') { + return fmt.Errorf("invalid character %q in name", r) + } + } + return nil +} + +// uniqueWorkspaceName ensures the workspace name is unique in the registry. +// If the exact same path and agent already exist, it returns the existing name +// so the workspace is updated rather than duplicated. +func uniqueWorkspaceName(baseName, path, agentCmd string) string { + projects, err := project.Load() + if err != nil { + return sanitizeName(baseName) + } + + for _, p := range projects { + if p.Path == path && p.AgentCommand == agentCmd { + return p.Name + } + } + + agentBin := "" + if agentCmd != "" { + fields := strings.Fields(agentCmd) + if len(fields) > 0 { + agentBin = filepath.Base(fields[0]) + } + } + + name := sanitizeName(baseName) + suffix := "" + + for i := 1; ; i++ { + if i == 2 && agentBin != "" { + suffix = "-" + sanitizeName(agentBin) + } else if i > 2 && agentBin != "" { + suffix = fmt.Sprintf("-%s-%d", sanitizeName(agentBin), i) + } else if i > 1 { + suffix = fmt.Sprintf("-%d", i) + } + + candidate := name + suffix + + collision := false + for _, p := range projects { + if p.Name == candidate { + collision = true + break + } + } + if !collision { + return candidate + } + } +} + +// sanitizeName strips out unsafe characters from the generated workspace name. +func sanitizeName(name string) string { + var b strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + b.WriteRune(r) + } + } + if b.Len() == 0 { + return "workspace" + } + return b.String() +} diff --git a/internal/initcmd/initcmd_test.go b/internal/initcmd/initcmd_test.go index fdd65a9..42685b8 100644 --- a/internal/initcmd/initcmd_test.go +++ b/internal/initcmd/initcmd_test.go @@ -1,6 +1,7 @@ package initcmd import ( + "fmt" "os" "path/filepath" "testing" @@ -11,13 +12,14 @@ import ( func TestInitCreatesStructureAndRegisters(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + t.Setenv("XDG_DATA_HOME", home) dir := filepath.Join(home, "work", "payments") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } - res, err := Init(dir, nil) + res, err := Init(dir, nil, "") if err != nil { t.Fatalf("Init: %v", err) } @@ -46,6 +48,7 @@ func TestInitCreatesStructureAndRegisters(t *testing.T) { func TestInitDoesNotClobberExistingClaudeMD(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + t.Setenv("XDG_DATA_HOME", home) dir := filepath.Join(home, "proj") if err := os.MkdirAll(dir, 0o755); err != nil { @@ -56,7 +59,7 @@ func TestInitDoesNotClobberExistingClaudeMD(t *testing.T) { t.Fatal(err) } - if _, err := Init(dir, nil); err != nil { + if _, err := Init(dir, nil, ""); err != nil { t.Fatalf("Init: %v", err) } @@ -67,13 +70,351 @@ func TestInitDoesNotClobberExistingClaudeMD(t *testing.T) { } func TestInitRejectsMissingDirectory(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_DATA_HOME", home) missing := filepath.Join(t.TempDir(), "does-not-exist") - if _, err := Init(missing, nil); err == nil { + if _, err := Init(missing, nil, ""); err == nil { t.Fatal("expected an error for a non-existent directory") } if _, err := os.Stat(missing); !os.IsNotExist(err) { t.Fatal("Init created a directory that should not exist") } } + +func TestInitRejectsClaudeDirectorySymlink(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_DATA_HOME", home) + + dir := filepath.Join(home, "workspace") + target := filepath.Join(home, "outside") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(dir, ".claude")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + if _, err := Init(dir, nil, ""); err == nil { + t.Fatal("Init accepted a symlinked .claude directory") + } + if _, err := os.Stat(filepath.Join(target, "crabby.yaml")); !os.IsNotExist(err) { + t.Fatalf("Init wrote through .claude symlink: %v", err) + } +} + +func TestInitRejectsCrabbyFileSymlink(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_DATA_HOME", home) + + dir := filepath.Join(home, "workspace") + target := filepath.Join(home, "outside.txt") + if err := os.MkdirAll(filepath.Join(dir, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + sentinel := []byte("do not overwrite\n") + if err := os.WriteFile(target, sentinel, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(dir, ".claude", "crabby.yaml")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + if _, err := Init(dir, nil, ""); err == nil { + t.Fatal("Init accepted a symlinked crabby.yaml") + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != string(sentinel) { + t.Fatalf("symlink target changed to %q", got) + } +} + +func TestUniqueWorkspaceName(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + pathA := filepath.Join(t.TempDir(), "path", "a", "myproj") + pathB := filepath.Join(t.TempDir(), "path", "b", "myproj") + pathC := filepath.Join(t.TempDir(), "path", "c", "myproj") + pathD := filepath.Join(t.TempDir(), "path", "d", "myproj") + + for _, p := range []string{pathA, pathB, pathC, pathD} { + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } + } + + // 1. Base name `myproj` at `/path/a` -> `myproj` + resA, err := Init(pathA, nil, "claude") + if err != nil { + t.Fatalf("Init pathA failed: %v", err) + } + if resA.Project.Name != "myproj" { + t.Fatalf("resA name = %q, want myproj", resA.Project.Name) + } + + // 2. Idempotent re-init at `/path/a` with same agent -> returns `myproj` + resA2, err := Init(pathA, nil, "claude") + if err != nil { + t.Fatalf("Init pathA re-init failed: %v", err) + } + if resA2.Project.Name != "myproj" { + t.Fatalf("resA2 re-init name = %q, want myproj", resA2.Project.Name) + } + + // 3. Base name `myproj` at `/path/b` with agent `opencode` -> `myproj-opencode` + resB, err := Init(pathB, nil, "opencode") + if err != nil { + t.Fatalf("Init pathB failed: %v", err) + } + if resB.Project.Name != "myproj-opencode" { + t.Fatalf("resB name = %q, want myproj-opencode", resB.Project.Name) + } + + // 4. Base name `myproj` at `/path/c` with agent `opencode` -> `myproj-opencode-3` + resC, err := Init(pathC, nil, "opencode") + if err != nil { + t.Fatalf("Init pathC failed: %v", err) + } + if resC.Project.Name != "myproj-opencode-3" { + t.Fatalf("resC name = %q, want myproj-opencode-3", resC.Project.Name) + } + + // 5. Base name `myproj` at `/path/d` with empty agent -> `myproj-2` + resD, err := Init(pathD, nil, "") + if err != nil { + t.Fatalf("Init pathD failed: %v", err) + } + if resD.Project.Name != "myproj-2" { + t.Fatalf("resD name = %q, want myproj-2", resD.Project.Name) + } +} + +func TestSanitizeName(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"myproj", "myproj"}, + {"My_Project-123", "My_Project-123"}, + {"my project @ 2026!", "myproject2026"}, + {"!!!", "workspace"}, + {"", "workspace"}, + } + + for _, c := range cases { + got := sanitizeName(c.input) + if got != c.want { + t.Errorf("sanitizeName(%q) = %q, want %q", c.input, got, c.want) + } + } +} + +// --- R1.4 Workspace Disambiguation Unit Tests (T1 & T2) ------------------- + +func TestDisambiguation_FirstWorkspaceBaseName(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + dir := filepath.Join(t.TempDir(), "myproject") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + res, err := Init(dir, nil, "claude") + if err != nil { + t.Fatalf("Init failed: %v", err) + } + if res.Project.Name != "myproject" { + t.Fatalf("res.Project.Name = %q, want myproject", res.Project.Name) + } +} + +func TestDisambiguation_SecondWorkspaceDifferentAgent(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + dir1 := filepath.Join(t.TempDir(), "path1", "myproject") + dir2 := filepath.Join(t.TempDir(), "path2", "myproject") + _ = os.MkdirAll(dir1, 0o755) + _ = os.MkdirAll(dir2, 0o755) + + _, _ = Init(dir1, nil, "claude") + res2, err := Init(dir2, nil, "opencode") + if err != nil { + t.Fatalf("Init dir2 failed: %v", err) + } + if res2.Project.Name != "myproject-opencode" { + t.Fatalf("res2.Project.Name = %q, want myproject-opencode", res2.Project.Name) + } +} + +func TestDisambiguation_ExactPathAndAgentReuse(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + dir := filepath.Join(t.TempDir(), "myproject") + _ = os.MkdirAll(dir, 0o755) + + res1, _ := Init(dir, nil, "opencode") + res2, err := Init(dir, nil, "opencode") + if err != nil { + t.Fatalf("re-init failed: %v", err) + } + if res2.Project.Name != res1.Project.Name { + t.Fatalf("re-init name = %q, want %q", res2.Project.Name, res1.Project.Name) + } +} + +func TestDisambiguation_MultipleSameAgentIncrementsIndex(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + dir1 := filepath.Join(t.TempDir(), "path1", "myproject") + dir2 := filepath.Join(t.TempDir(), "path2", "myproject") + dir3 := filepath.Join(t.TempDir(), "path3", "myproject") + _ = os.MkdirAll(dir1, 0o755) + _ = os.MkdirAll(dir2, 0o755) + _ = os.MkdirAll(dir3, 0o755) + + _, _ = Init(dir1, nil, "claude") + _, _ = Init(dir2, nil, "opencode") + res3, err := Init(dir3, nil, "opencode") + if err != nil { + t.Fatalf("Init dir3 failed: %v", err) + } + if res3.Project.Name != "myproject-opencode-3" { + t.Fatalf("res3.Project.Name = %q, want myproject-opencode-3", res3.Project.Name) + } +} + +func TestDisambiguation_EmptyAgentCommandDisambiguation(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + dir1 := filepath.Join(t.TempDir(), "a", "proj") + dir2 := filepath.Join(t.TempDir(), "b", "proj") + _ = os.MkdirAll(dir1, 0o755) + _ = os.MkdirAll(dir2, 0o755) + + _, _ = Init(dir1, nil, "") + res2, err := Init(dir2, nil, "") + if err != nil { + t.Fatalf("Init dir2 failed: %v", err) + } + if res2.Project.Name != "proj-2" { + t.Fatalf("res2.Project.Name = %q, want proj-2", res2.Project.Name) + } +} + +func TestDisambiguation_SpecialCharsInBaseName(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + dir := filepath.Join(t.TempDir(), "my.app#1!") + _ = os.MkdirAll(dir, 0o755) + + name := uniqueWorkspaceName("my.app#1!", dir, "claude") + if name != "myapp1" { + t.Fatalf("uniqueWorkspaceName = %q, want myapp1", name) + } +} + +func TestDisambiguation_SanitizeToEmptyFallback(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + dir := filepath.Join(t.TempDir(), "!!!$$$") + _ = os.MkdirAll(dir, 0o755) + + name := uniqueWorkspaceName("!!!$$$", dir, "claude") + if name != "workspace" { + t.Fatalf("uniqueWorkspaceName = %q, want workspace", name) + } +} + +func TestDisambiguation_ComplexAgentCommandWithFlags(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + dir1 := filepath.Join(t.TempDir(), "a", "myproj") + dir2 := filepath.Join(t.TempDir(), "b", "myproj") + _ = os.MkdirAll(dir1, 0o755) + _ = os.MkdirAll(dir2, 0o755) + + _, _ = Init(dir1, nil, "claude") + res2, err := Init(dir2, nil, "/usr/local/bin/claude --permission-mode auto --verbose") + if err != nil { + t.Fatalf("Init dir2 failed: %v", err) + } + if res2.Project.Name != "myproj-claude" { + t.Fatalf("res2.Project.Name = %q, want myproj-claude", res2.Project.Name) + } +} + +func TestDisambiguation_PathCaseSensitivity(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + dir1 := filepath.Join(t.TempDir(), "path1", "Project") + dir2 := filepath.Join(t.TempDir(), "path2", "Project") + _ = os.MkdirAll(dir1, 0o755) + _ = os.MkdirAll(dir2, 0o755) + + res1, _ := Init(dir1, nil, "claude") + name := uniqueWorkspaceName("Project", dir2, "claude") + if name == res1.Project.Name { + t.Fatalf("expected different name for different path %q vs %q, got same %q", dir1, dir2, name) + } +} + +func TestDisambiguation_LargeCollisionCount(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + base := t.TempDir() + // Register base project "myproj" + p1 := project.Project{ + Name: "myproj", + Path: filepath.Join(base, "myproj"), + AgentCommand: "claude", + } + if err := project.Register(p1); err != nil { + t.Fatalf("Register p1 failed: %v", err) + } + + // Register collateral colliding projects myproj-claude, myproj-claude-3 ... myproj-claude-50 + for i := 2; i <= 50; i++ { + var name string + if i == 2 { + name = "myproj-claude" + } else { + name = fmt.Sprintf("myproj-claude-%d", i) + } + p := project.Project{ + Name: name, + Path: filepath.Join(base, fmt.Sprintf("myproj-dir%d", i)), + AgentCommand: "claude", + } + if err := project.Register(p); err != nil { + t.Fatalf("Register failed: %v", err) + } + } + + targetDir := filepath.Join(base, "myproj-dir51") + _ = os.MkdirAll(targetDir, 0o755) + + name := uniqueWorkspaceName("myproj", targetDir, "claude") + if name != "myproj-claude-51" { + t.Fatalf("uniqueWorkspaceName = %q, want myproj-claude-51", name) + } +} diff --git a/internal/insights/insights.go b/internal/insights/insights.go index 488f13e..8f498c1 100644 --- a/internal/insights/insights.go +++ b/internal/insights/insights.go @@ -1,8 +1,8 @@ -// Package insights reads Claude Code's own session transcripts to surface +// Package insights reads AI Agent's own session transcripts to surface // factual, per-project information on the dashboard — the model in use, tokens -// spent, and a best-effort hint of what Claude is doing right now. +// spent, and a best-effort hint of what Agent is doing right now. // -// Everything here comes from the JSONL transcripts Claude Code writes under +// Everything here comes from the JSONL transcripts AI Agent writes under // ~/.claude/projects//.jsonl. That is a durable, // append-only file source: no terminal scraping, no guessing. Fields that // cannot be read reliably are simply left empty rather than estimated. @@ -11,7 +11,7 @@ // - Activity is inferred from the tail of the transcript, which can lag the // live terminal by a moment; callers gate it on tmux's "working" signal. // - A project is matched to its transcript directory by re-encoding its path -// the way Claude Code does ('/' and '.' become '-'); if that directory is +// the way AI Agent does ('/' and '.' become '-'); if that directory is // absent we fall back to reading the `cwd` recorded inside each transcript. // - There is no reliable source for progress percentages or completion times, // so none are produced. @@ -27,7 +27,7 @@ import ( "time" ) -// Activity is a coarse, transcript-derived hint of Claude's current action. It +// Activity is a coarse, transcript-derived hint of the Agent's current action. It // describes what the last recorded step was; callers decide how to present it // (e.g. only while the session is actively producing output). type Activity string @@ -74,7 +74,7 @@ type fileState struct { detail string } -// New returns a Collector rooted at the current user's Claude projects +// New returns a Collector rooted at the current user's Agent projects // directory. If it cannot be located, the Collector still works and simply // reports every project as having no transcript. func New() *Collector { @@ -134,12 +134,12 @@ func (c *Collector) For(path string) Insight { cur := c.update(current, sizeOf(current)) ins := Insight{ Found: true, - Model: friendlyModel(cur.model), + Model: sanitizeString(friendlyModel(cur.model)), SessionTokens: sum(cur.tokensByDay), TodayTokens: todayTokens, LastActivity: cur.lastTS, Activity: cur.activity, - Detail: cur.detail, + Detail: sanitizeString(cur.detail), } return ins } @@ -219,7 +219,7 @@ func (st *fileState) apply(line []byte) { return } if rec.Message.Role == "assistant" { - // Claude Code tags some injected records with a "" model; + // AI Agent tags some injected records with a "" model; // only accept genuine model ids so that marker never reaches the UI. if strings.HasPrefix(rec.Message.Model, "claude-") { st.model = rec.Message.Model @@ -230,14 +230,14 @@ func (st *fileState) apply(line []byte) { } st.activity, st.detail = activityOf(rec.Message.Content) } else if rec.Message.Role == "user" { - // A user turn means Claude's previous turn is finished; the next + // A user turn means the Agent's previous turn is finished; the next // assistant record will set the real activity again. st.activity, st.detail = Responding, "" } } // activityOf reads an assistant message's content blocks and returns what the -// final meaningful block indicates Claude is doing. +// final meaningful block indicates Agent is doing. func activityOf(content json.RawMessage) (Activity, string) { var blocks []contentBlock if json.Unmarshal(content, &blocks) != nil || len(blocks) == 0 { @@ -262,7 +262,7 @@ func activityOf(content json.RawMessage) (Activity, string) { return Unknown, "" } -// transcriptDir resolves the Claude Code transcript directory for a project +// transcriptDir resolves the AI Agent transcript directory for a project // path, preferring the direct encoding and falling back to a one-time scan that // matches the `cwd` recorded inside transcripts. Callers hold c.mu. func (c *Collector) transcriptDir(path string) string { @@ -303,7 +303,7 @@ func (c *Collector) scanForCwd(path string) string { return "" } -// encodePath mirrors Claude Code's transcript-directory naming: every '/' and +// encodePath mirrors AI Agent's transcript-directory naming: every '/' and // '.' in the absolute path becomes '-'. func encodePath(path string) string { return strings.NewReplacer("/", "-", ".", "-").Replace(path) @@ -325,12 +325,29 @@ func friendlyModel(id string) string { case strings.HasPrefix(id, "claude-fable-5"): return "Fable 5" } - // Unknown id: strip the vendor prefix so at least something readable shows. - return strings.TrimPrefix(id, "claude-") + // Unknown id: strip vendor prefix so synthetic/future models present cleanly. + if strings.HasPrefix(id, "claude-") { + return strings.TrimPrefix(id, "claude-") + } + return id } // --- small helpers --------------------------------------------------------- +func sanitizeString(s string) string { + var b strings.Builder + for _, r := range s { + if r == 0x1b || r == 0x7f { + continue + } + if (r >= 0x00 && r <= 0x1f && r != '\n' && r != '\t') || (r >= 0x80 && r <= 0x9f) { + continue + } + b.WriteRune(r) + } + return b.String() +} + func sum(m map[string]int) int { total := 0 for _, v := range m { diff --git a/internal/pack/manage.go b/internal/pack/manage.go index f7add6d..5b3000b 100644 --- a/internal/pack/manage.go +++ b/internal/pack/manage.go @@ -150,6 +150,9 @@ func copyTree(src, dest string) error { if d.IsDir() { return os.MkdirAll(target, 0o755) } + if d.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("pack contains symlink %q — not allowed", rel) + } return copyFile(path, target) }) } diff --git a/internal/pack/pack.go b/internal/pack/pack.go index 5d76347..14659ad 100644 --- a/internal/pack/pack.go +++ b/internal/pack/pack.go @@ -101,6 +101,9 @@ func Load(dir string) (Pack, error) { if p.Name == "" { return Pack{}, fmt.Errorf("%s: %s is missing a 'name'", filepath.Base(abs), manifest) } + if err := validateName(p.Name); err != nil { + return Pack{}, err + } return p, nil } @@ -124,6 +127,9 @@ func Apply(p Pack, projectDir string) (copied, skipped []string, err error) { if d.IsDir() { return os.MkdirAll(dest, 0o755) } + if d.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("pack contains symlink %q — not allowed", rel) + } if _, statErr := os.Stat(dest); statErr == nil { skipped = append(skipped, rel) return nil @@ -138,6 +144,14 @@ func Apply(p Pack, projectDir string) (copied, skipped []string, err error) { } func copyFile(src, dest string) error { + info, err := os.Lstat(src) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symlink not allowed: %s", src) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return err } @@ -147,7 +161,7 @@ func copyFile(src, dest string) error { } defer in.Close() - info, err := in.Stat() + info, err = in.Stat() if err != nil { return err } @@ -177,6 +191,19 @@ func parseManifest(data []byte) Pack { } key := strings.TrimSpace(line[:idx]) val := strings.Trim(strings.TrimSpace(line[idx+1:]), `"'`) + + var b strings.Builder + for _, r := range val { + if r == 0x1b || r == 0x7f { + continue + } + if (r >= 0x00 && r <= 0x1f && r != '\n' && r != '\t') || (r >= 0x80 && r <= 0x9f) { + continue + } + b.WriteRune(r) + } + val = b.String() + switch key { case "name": p.Name = val diff --git a/internal/project/project.go b/internal/project/project.go index 3f75484..e87095f 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -8,9 +8,11 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "strings" + "syscall" "time" ) @@ -25,21 +27,23 @@ var ErrTaskExists = errors.New("a task with that name already exists") // existed. Its session keeps the legacy "crabby_" name. const DefaultTask = "main" -// Task is one Claude Code session inside a workspace. A workspace may hold +// Task is one AI Agent session inside a workspace. A workspace may hold // several, each an independent session sharing the same project directory. type Task struct { - Name string `json:"name"` - Session string `json:"session"` - Created time.Time `json:"created,omitempty"` + Name string `json:"name"` + Session string `json:"session"` + Created time.Time `json:"created,omitempty"` + AgentCommand string `json:"agent_command,omitempty"` } // Project is a registered workspace. It owns one or more tasks; the legacy // Session field is kept only so older registries migrate cleanly. type Project struct { - Name string `json:"name"` - Path string `json:"path"` - Session string `json:"session,omitempty"` // legacy single session (pre-tasks) - Tasks []Task `json:"tasks,omitempty"` + Name string `json:"name"` + Path string `json:"path"` + Session string `json:"session,omitempty"` // legacy single session (pre-tasks) + Tasks []Task `json:"tasks,omitempty"` + AgentCommand string `json:"agent_command,omitempty"` } // withTasks fills in the implicit default task for a workspace that has none, @@ -84,6 +88,10 @@ func Load() ([]Project, error) { return nil, err } + return loadPath(path) +} + +func loadPath(path string) ([]Project, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -112,34 +120,154 @@ func Save(projects []Project) error { if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := os.Chmod(dir, 0o700); err != nil { return err } + return withRegistryLock(path, func() error { + return saveLocked(path, projects) + }) +} + +func withRegistryLock(path string, fn func() error) error { + dir := filepath.Dir(path) + lockPath := filepath.Join(dir, ".lock") + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer lockFile.Close() + if err := os.Chmod(lockPath, 0o600); err != nil { + return err + } + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil { + return err + } + defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) + return fn() +} + +func saveLocked(path string, projects []Project) error { + dir := filepath.Dir(path) data, err := json.MarshalIndent(projects, "", " ") if err != nil { return err } data = append(data, '\n') - return os.WriteFile(path, data, 0o644) + + tmp, err := os.CreateTemp(dir, ".projects.json-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + removeTemp := true + defer func() { + _ = tmp.Close() + if removeTemp { + _ = os.Remove(tmpPath) + } + }() + if err := tmp.Chmod(0o600); err != nil { + return err + } + if _, err := tmp.Write(data); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + removeTemp = false + return os.Chmod(path, 0o600) } -// Register adds a project or updates the existing entry with the same name. -func Register(p Project) error { - projects, err := Load() +func updateRegistry(mutator func([]Project) ([]Project, error)) error { + path, err := dbPath() if err != nil { return err } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := os.Chmod(dir, 0o700); err != nil { + return err + } + return withRegistryLock(path, func() error { + projects, err := loadPath(path) + if err != nil { + return err + } + projects, err = mutator(projects) + if err != nil { + return err + } + return saveLocked(path, projects) + }) +} - for i := range projects { - if projects[i].Name == p.Name { - projects[i] = p - return Save(projects) +// Register adds a project or updates the existing entry with the same name. +func Register(p Project) error { + return updateRegistry(func(projects []Project) ([]Project, error) { + if err := validateProjectSessions(projects, p); err != nil { + return nil, err + } + for i := range projects { + if projects[i].Name == p.Name { + projects[i] = p + return projects, nil + } + } + return append(projects, p), nil + }) +} + +func validateProjectSessions(projects []Project, p Project) error { + for _, other := range projects { + if other.Name == p.Name { + continue + } + for _, t := range p.Tasks { + for _, ot := range other.Tasks { + if t.Session == ot.Session { + return fmt.Errorf("session %q collides with workspace %q", t.Session, other.Name) + } + } + if other.Session != "" && t.Session == other.Session { + return fmt.Errorf("session %q collides with workspace %q", t.Session, other.Name) + } + } + if p.Session != "" { + for _, ot := range other.Tasks { + if p.Session == ot.Session { + return fmt.Errorf("session %q collides with workspace %q", p.Session, other.Name) + } + } + if other.Session != "" && p.Session == other.Session { + return fmt.Errorf("session %q collides with workspace %q", p.Session, other.Name) + } } } + return nil +} - projects = append(projects, p) - return Save(projects) +// validateWorkspaceName ensures the workspace name contains only safe characters. +func validateWorkspaceName(name string) error { + if name == "" { + return errors.New("name cannot be empty") + } + for _, r := range name { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-') { + return fmt.Errorf("invalid character %q in name", r) + } + } + return nil } // Relink points an existing workspace at a new directory — for when its folder @@ -157,47 +285,46 @@ func Relink(oldName, newPath string) error { return fmt.Errorf("%q is not a directory", newPath) } newName := filepath.Base(abs) - - projects, err := Load() - if err != nil { + if err := validateWorkspaceName(newName); err != nil { return err } - idx := -1 - for i := range projects { - if projects[i].Name == oldName { - idx = i - } else if projects[i].Name == newName { - return fmt.Errorf("a workspace named %q already exists", newName) + + return updateRegistry(func(projects []Project) ([]Project, error) { + idx := -1 + for i := range projects { + if projects[i].Name == oldName { + idx = i + } else if projects[i].Name == newName { + return nil, fmt.Errorf("a workspace named %q already exists", newName) + } } - } - if idx < 0 { - return ErrNotFound - } - projects[idx].Name = newName - projects[idx].Path = abs - return Save(projects) + if idx < 0 { + return nil, ErrNotFound + } + projects[idx].Name = newName + projects[idx].Path = abs + return projects, nil + }) } // Remove deletes a project from the registry. It does not touch any files on // disk. Returns ErrNotFound if no such project is registered. func Remove(name string) error { - projects, err := Load() - if err != nil { - return err - } - out := make([]Project, 0, len(projects)) - found := false - for _, p := range projects { - if p.Name == name { - found = true - continue + return updateRegistry(func(projects []Project) ([]Project, error) { + out := make([]Project, 0, len(projects)) + found := false + for _, p := range projects { + if p.Name == name { + found = true + continue + } + out = append(out, p) } - out = append(out, p) - } - if !found { - return ErrNotFound - } - return Save(out) + if !found { + return nil, ErrNotFound + } + return out, nil + }) } // Find returns the project with the given name. @@ -217,72 +344,100 @@ func Find(name string) (Project, error) { // AddTask registers a new task in a workspace. It fails if the workspace is // unknown or the task name is already taken. func AddTask(workspace string, t Task) error { - projects, err := Load() - if err != nil { - return err - } - for i := range projects { - if projects[i].Name != workspace { - continue + return updateRegistry(func(projects []Project) ([]Project, error) { + for _, p := range projects { + if p.Name == workspace { + continue + } + for _, ot := range p.Tasks { + if t.Session == ot.Session { + return nil, fmt.Errorf("session %q collides with workspace %q", t.Session, p.Name) + } + } + if p.Session != "" && t.Session == p.Session { + return nil, fmt.Errorf("session %q collides with workspace %q", t.Session, p.Name) + } } - if _, exists := projects[i].Task(t.Name); exists { - return ErrTaskExists + for i := range projects { + if projects[i].Name != workspace { + continue + } + if _, exists := projects[i].Task(t.Name); exists { + return nil, ErrTaskExists + } + projects[i].Tasks = append(projects[i].Tasks, t) + return projects, nil } - projects[i].Tasks = append(projects[i].Tasks, t) - return Save(projects) - } - return ErrNotFound + return nil, ErrNotFound + }) } // RemoveTask forgets a task. Removing the last task leaves the workspace itself // in place, ready to accept new tasks. func RemoveTask(workspace, taskName string) error { - projects, err := Load() - if err != nil { - return err - } - for i := range projects { - if projects[i].Name != workspace { - continue - } - kept := projects[i].Tasks[:0] - found := false - for _, t := range projects[i].Tasks { - if t.Name == taskName { - found = true + return updateRegistry(func(projects []Project) ([]Project, error) { + for i := range projects { + if projects[i].Name != workspace { continue } - kept = append(kept, t) - } - if !found { - return fmt.Errorf("task %q not found in %q", taskName, workspace) + kept := projects[i].Tasks[:0] + found := false + for _, t := range projects[i].Tasks { + if t.Name == taskName { + found = true + continue + } + kept = append(kept, t) + } + if !found { + return nil, fmt.Errorf("task %q not found in %q", taskName, workspace) + } + // Drop the legacy field so a fully task-managed workspace stops + // resurrecting the old default on the next load. + projects[i].Tasks = kept + projects[i].Session = "" + return projects, nil } - // Drop the legacy field so a fully task-managed workspace stops - // resurrecting the old default on the next load. - projects[i].Tasks = kept - projects[i].Session = "" - return Save(projects) - } - return ErrNotFound + return nil, ErrNotFound + }) } // Branch returns the current git branch for display, or "" if the project is // not a git repository. It reads .git/HEAD directly — Crabby does not run git // or perform any git operations; this is purely informational. func (p Project) Branch() string { - data, err := os.ReadFile(filepath.Join(p.Path, ".git", "HEAD")) + headPath := filepath.Join(p.Path, ".git", "HEAD") + info, err := os.Lstat(headPath) + if err != nil || info.Mode()&os.ModeSymlink != 0 { + return "" + } + f, err := os.Open(headPath) + if err != nil { + return "" + } + defer f.Close() + + data, err := io.ReadAll(io.LimitReader(f, 8192)) if err != nil { return "" } head := strings.TrimSpace(string(data)) - if ref, ok := strings.CutPrefix(head, "ref: refs/heads/"); ok { - return ref + + var ref string + if r, ok := strings.CutPrefix(head, "ref: refs/heads/"); ok { + ref = r + } else if len(head) >= 7 && isHex(head) { + ref = head[:7] + } else { + return "" } - // Detached HEAD: show a short commit hash if it looks like one. - if len(head) >= 7 && isHex(head) { - return head[:7] + + for _, r := range ref { + if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '/' || r == '-') { + return "" + } } - return "" + return ref } func isHex(s string) bool { diff --git a/internal/project/project_test.go b/internal/project/project_test.go index a0e82f1..8b3ab70 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -1,6 +1,11 @@ package project -import "testing" +import ( + "os" + "path/filepath" + "sync" + "testing" +) func TestRegisterFindAndUpdate(t *testing.T) { t.Setenv("HOME", t.TempDir()) @@ -142,3 +147,91 @@ func TestRemove(t *testing.T) { t.Fatalf("Remove(missing) = %v, want ErrNotFound", err) } } + +func TestRegistryPermissionsArePrivate(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := Save([]Project{{Name: "private", Path: "/private", Session: "crabby_private"}}); err != nil { + t.Fatal(err) + } + dir := filepath.Join(home, ".local", "share", "crabby") + for _, path := range []string{dir, filepath.Join(dir, "projects.json"), filepath.Join(dir, ".lock")} { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + if got := info.Mode().Perm(); got != map[string]os.FileMode{ + dir: 0o700, + filepath.Join(dir, "projects.json"): 0o600, + filepath.Join(dir, ".lock"): 0o600, + }[path] { + t.Fatalf("permissions for %s = %o, want %o", path, got, map[string]os.FileMode{ + dir: 0o700, + filepath.Join(dir, "projects.json"): 0o600, + filepath.Join(dir, ".lock"): 0o600, + }[path]) + } + } + + if err := os.Chmod(filepath.Join(dir, "projects.json"), 0o644); err != nil { + t.Fatal(err) + } + if err := Save([]Project{{Name: "private", Path: "/private", Session: "crabby_private"}}); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dir, "projects.json")) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("existing registry permissions = %o, want 600", got) + } +} + +func TestConcurrentRegistryUpdatesArePreserved(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + const count = 8 + var wg sync.WaitGroup + errs := make(chan error, count) + for i := 0; i < count; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs <- Register(Project{ + Name: "workspace-" + string(rune('a'+i)), + Path: "/workspace/" + string(rune('a'+i)), + Session: "crabby_workspace_" + string(rune('a'+i)), + }) + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + projects, err := Load() + if err != nil { + t.Fatal(err) + } + if len(projects) != count { + t.Fatalf("concurrent Register preserved %d projects, want %d", len(projects), count) + } +} + +func TestRegisterRejectsSessionCollisionAcrossWorkspaces(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := Register(Project{Name: "foo_bar", Path: "/foo_bar", Session: "crabby_foo_bar"}); err != nil { + t.Fatal(err) + } + err := Register(Project{ + Name: "foo", + Path: "/foo", + Tasks: []Task{{Name: "bar", Session: "crabby_foo_bar"}}, + }) + if err == nil { + t.Fatal("Register accepted a colliding session") + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 9a73ad6..0913608 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -4,6 +4,8 @@ package session import ( + "strings" + "github.com/marioolf/crabby/internal/project" "github.com/marioolf/crabby/internal/tmux" ) @@ -20,16 +22,26 @@ const ( Stopped State = "Stopped" ) +func sanitize(name string) string { + var b strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + b.WriteRune(r) + } + } + return b.String() +} + // Name returns the tmux session name for a workspace's default task. Kept as // "crabby_" so single-task workspaces match their pre-tasks name. func Name(projectName string) string { - return "crabby_" + projectName + return "crabby_" + sanitize(projectName) } // TaskSession builds the tmux session name for a named task, e.g. // "crabby_payments_tests". Users never type these — Crabby manages them. func TaskSession(workspace, task string) string { - return "crabby_" + workspace + "_" + task + return "crabby_" + sanitize(workspace) + "_" + sanitize(task) } // Classify maps a session's presence and attachment into a State. Used by the diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 98ba204..89990a8 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -58,8 +58,24 @@ func (c Client) Available() bool { return err == nil } +// isValidSessionName ensures the session name contains only safe characters. +func isValidSessionName(name string) bool { + if name == "" { + return false + } + for _, r := range name { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == ':') { + return false + } + } + return true +} + // HasSession reports whether a session with the given name exists. func (c Client) HasSession(name string) bool { + if !isValidSessionName(name) { + return false + } return exec.Command(c.Binary, c.args("has-session", "-t", exact(name))...).Run() == nil } @@ -69,6 +85,9 @@ func (c Client) HasSession(name string) bool { // "=" exact-match form, so callers must pass a full session name. The dashboard // reads attachment from ListSessions instead, which keys on exact names. func (c Client) Attached(name string) bool { + if !isValidSessionName(name) { + return false + } out, err := c.output("display-message", "-p", "-t", name, "#{session_attached}") if err != nil { return false @@ -115,6 +134,9 @@ func (c Client) ListSessions() map[string]Session { // Activity returns the time of the session's last activity. func (c Client) Activity(name string) (time.Time, bool) { + if !isValidSessionName(name) { + return time.Time{}, false + } out, err := c.output("display-message", "-p", "-t", name, "#{session_activity}") if err != nil { return time.Time{}, false @@ -129,6 +151,9 @@ func (c Client) Activity(name string) (time.Time, bool) { // NewSession creates a detached session named name, starting in dir and running // the given command (empty opens a shell). func (c Client) NewSession(name, dir, command string) error { + if !isValidSessionName(name) { + return fmt.Errorf("invalid session name: %q", name) + } a := []string{"new-session", "-d", "-s", name, "-c", dir} if command != "" { a = append(a, command) @@ -139,22 +164,31 @@ func (c Client) NewSession(name, dir, command string) error { // KillSession ends a session (and the program running in it). Used by the home // screen's stop key so the user never has to touch tmux directly. func (c Client) KillSession(name string) error { + if !isValidSessionName(name) { + return fmt.Errorf("invalid session name: %q", name) + } return c.run("kill-session", "-t", exact(name)) } // RenameWindow sets the session's window name, which Crabby uses to show the // project name in the status bar instead of the running command. func (c Client) RenameWindow(session, name string) error { + if !isValidSessionName(session) { + return fmt.Errorf("invalid session name: %q", session) + } return c.run("rename-window", "-t", exact(session), name) } // AttachCmd builds the command that attaches to a session, wired to the real // terminal. It does not run: the TUI wraps it for tea.Exec so Bubble Tea can // release the screen, run the attach, and restore itself afterwards — a seamless -// step in and out of a Claude session without ever leaving Crabby. The session +// step in and out of a AI Agent session without ever leaving Crabby. The session // name is passed as a single argv element, so names with unusual characters are // never word-split or interpreted by a shell. func (c Client) AttachCmd(name string) *exec.Cmd { + if !isValidSessionName(name) { + return nil // Should not happen in normal flow, but defends against injection + } cmd := exec.Command(c.Binary, c.args("attach-session", "-t", exact(name))...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout @@ -169,6 +203,9 @@ func (c Client) AttachCmd(name string) *exec.Cmd { // any prefix, so this cannot land on the wrong session. A missing session or any // error yields "". func (c Client) CapturePane(name string) string { + if !isValidSessionName(name) { + return "" + } out, err := c.output("capture-pane", "-t", name, "-p") if err != nil { return "" @@ -192,7 +229,7 @@ func (c Client) Configure(detachKey string) error { {"set-option", "-g", "allow-rename", "off"}, {"set-option", "-g", "automatic-rename", "off"}, // Let the wheel scroll the pane's history instead of being forwarded to - // Claude Code as arrow keys — otherwise scrolling up cycles through old + // AI Agent as arrow keys — otherwise scrolling up cycles through old // prompts rather than the response you're trying to read. {"set-option", "-g", "mouse", "on"}, {"set-option", "-g", "status", "on"}, diff --git a/internal/tmux/tmux_integration_test.go b/internal/tmux/tmux_integration_test.go index ef30969..9de01d9 100644 --- a/internal/tmux/tmux_integration_test.go +++ b/internal/tmux/tmux_integration_test.go @@ -97,3 +97,10 @@ func TestExactTargetsAvoidPrefixMatching(t *testing.T) { t.Fatalf("KillSession(%q) did not remove it", base) } } + +func TestInvalidSessionNamesNeverBuildAttachCommand(t *testing.T) { + c := New("tmux") + if cmd := c.AttachCmd("crabby_bad; run-shell 'owned'"); cmd != nil { + t.Fatal("AttachCmd built a command for an invalid session name") + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 77d34b9..5a4177b 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1,10 +1,10 @@ // Package tui implements Crabby's terminal application. // // From v0.8 Crabby is TUI-first: `crabby` opens a persistent, multi-panel home -// screen and everything — creating workspaces and tasks, attaching to Claude, +// screen and everything — creating workspaces and tasks, attaching to an AI Agent, // managing packs, diagnostics — happens inside it. The shell is no longer part // of the normal workflow. A single Bubble Tea program owns the whole session; -// attaching to a Claude session is done through tea.ExecProcess so the screen is +// attaching to a AI Agent session is done through tea.ExecProcess so the screen is // released and restored around it, without ever leaving Crabby. package tui @@ -28,6 +28,13 @@ import ( "github.com/marioolf/crabby/internal/tmux" ) +// AgentItem represents a dynamically detected AI agent +type AgentItem struct { + Name string + Command string + AutoApproveFlag string +} + const refreshEach = time.Second // animEach is how often the banner redraws. It only rebuilds the view (no disk @@ -68,7 +75,7 @@ type model struct { width, height int screen screen - // afterAttach is the screen to restore when a Claude session ends — so + // afterAttach is the screen to restore when a AI Agent session ends — so // leaving a session opened from Mission Control returns there, not to the // dashboard. afterAttach screen @@ -93,11 +100,15 @@ type model struct { mc missionState // --- New-workspace flow ------------------------------------------------ - wsStep wsStep - wsDir string - wsMatches []string // sub-directories matching the typed path, for completion - packList []pack.Pack - packCursor int + wsStep wsStep + wsDir string + wsMatches []string // sub-directories matching the typed path, for completion + packList []pack.Pack + packCursor int + agentList []AgentItem + agentCursor int + agentAutoApprove bool + wsPack *pack.Pack // --- Import flow ------------------------------------------------------- importStep importStep @@ -109,7 +120,9 @@ type model struct { relinkProj project.Project // --- New-task flow ----------------------------------------------------- - formProj project.Project + formProj project.Project + newTaskStep newTaskStep + newTaskName string // --- Packs screen ------------------------------------------------------ packs []pack.Pack @@ -135,7 +148,7 @@ type model struct { // Run starts the persistent application and blocks until the user quits. The // collector is reused across the whole session so its incremental transcript -// cache survives returning from a Claude session. +// cache survives returning from a AI Agent session. func Run(cfg config.Config, t tmux.Client, coll *insights.Collector) error { if coll == nil { coll = insights.New() @@ -162,13 +175,13 @@ type tickMsg time.Time type animMsg time.Time // openTaskMsg asks the app to attach to a task, creating its session first if -// needed. Every "open a Claude session" path funnels through here. +// needed. Every "open a AI Agent session" path funnels through here. type openTaskMsg struct { proj project.Project task project.Task } -// execDoneMsg is delivered after an external program (a Claude attach, or an +// execDoneMsg is delivered after an external program (a Agent attach, or an // editor) finishes and the screen has been restored. type execDoneMsg struct{ err error } @@ -329,9 +342,9 @@ func (m model) View() string { } } -// attach opens a task's Claude session. Quick tmux calls (create, configure, +// attach opens a task's AI Agent session. Quick tmux calls (create, configure, // rename) run inline; the blocking attach is handed to tea.ExecProcess so Bubble -// Tea releases the screen for Claude and restores the dashboard afterwards. +// Tea releases the screen for Agent and restores the dashboard afterwards. func (m model) attach(p project.Project, task project.Task) (tea.Model, tea.Cmd) { // Remember where to land when the session ends: back in Mission Control if // that's where we came from, otherwise the dashboard. @@ -341,12 +354,23 @@ func (m model) attach(p project.Project, task project.Task) (tea.Model, tea.Cmd) m.afterAttach = scrDashboard } if !m.tmux.HasSession(task.Session) { - if _, err := exec.LookPath(m.cfg.ClaudeCommand); err != nil { - m.notice = fmt.Sprintf("Claude Code not found on PATH (looked for %q)", m.cfg.ClaudeCommand) + cmd := task.AgentCommand + if cmd == "" { + cmd = p.AgentCommand + } + if cmd == "" { + cmd = m.cfg.ClaudeCommand + } + bin := cmd + if fields := strings.Fields(cmd); len(fields) > 0 { + bin = fields[0] + } + if _, err := exec.LookPath(bin); err != nil { + m.notice = fmt.Sprintf("Agent not found on PATH (looked for %q)", cmd) m.noticeErr = true return m, nil } - if err := m.tmux.NewSession(task.Session, p.Path, m.cfg.ClaudeCommand); err != nil { + if err := m.tmux.NewSession(task.Session, p.Path, cmd); err != nil { m.notice = fmt.Sprintf("could not start task %q: %v", task.Name, err) m.noticeErr = true return m, nil @@ -357,7 +381,13 @@ func (m model) attach(p project.Project, task project.Task) (tea.Model, tea.Cmd) // means a plain status bar, so never block the attach on it. _ = m.tmux.Configure(m.cfg.DetachKey) m.notice, m.noticeErr = "", false - ex := &attachExec{cmd: m.tmux.AttachCmd(task.Session)} + attach := m.tmux.AttachCmd(task.Session) + if attach == nil { + m.notice = fmt.Sprintf("invalid session name for task %q", task.Name) + m.noticeErr = true + return m, nil + } + ex := &attachExec{cmd: attach} return m, tea.Exec(ex, func(err error) tea.Msg { return execDoneMsg{err} }) } diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 6fb9c6f..c121c06 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -423,7 +423,7 @@ func (m model) workspaceColumn(w, h int) string { selected := i == m.wsCursor st, working := r.repr() dot, dotStyle := glyph(st, working) - name := r.proj.Name + name := sanitizeRender(r.proj.Name) nameR := nameStyle.Render(name) if selected { nameR = selNameStyle.Render(name) @@ -489,9 +489,9 @@ func (m model) detailColumn(w, h int) string { if ws.missing { add("folder", errorStyle.Render("⚠ missing — moved or renamed (e to relink)")) } - add("branch", ws.branch) + add("branch", sanitizeRender(ws.branch)) if ins.Model != "" { - add("model", ins.Model) + add("model", sanitizeRender(ins.Model)) } if ins.SessionTokens > 0 { add("tokens", formatTokens(ins.SessionTokens)+" session") @@ -503,7 +503,7 @@ func (m model) detailColumn(w, h int) string { add("uptime", formatDuration(tr.uptime)) } if tr.working { - add("activity", workingActivity(ins)) + add("activity", sanitizeRender(workingActivity(ins))) } if !ins.LastActivity.IsZero() { add("last", formatAgo(time.Since(ins.LastActivity))) @@ -548,7 +548,7 @@ func (m model) summaryLine() string { } // dashboardFooter shows the actions and the on-screen hint for returning from a -// Claude session — the single, discoverable answer to "how do I get back?". +// AI Agent session — the single, discoverable answer to "how do I get back?". func (m model) dashboardFooter() string { line1 := strings.Join([]string{ actionKey("↑↓", "move"), actionKey("tab", "pane"), diff --git a/internal/tui/forms.go b/internal/tui/forms.go index 0d98551..6d3f1bd 100644 --- a/internal/tui/forms.go +++ b/internal/tui/forms.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "os" + "os/exec" "path/filepath" "sort" "strings" @@ -22,8 +23,25 @@ type wsStep int const ( wsStepDir wsStep = iota wsStepPack + wsStepAgent ) +func getInstalledAgents() []AgentItem { + candidates := []AgentItem{ + {Name: "Claude Code", Command: "claude", AutoApproveFlag: "--permission-mode auto"}, + {Name: "Antigravity CLI", Command: "agy", AutoApproveFlag: "--dangerously-skip-permissions"}, + {Name: "Aider", Command: "aider", AutoApproveFlag: "--yes-always"}, + {Name: "OpenCode", Command: "opencode", AutoApproveFlag: "--auto"}, + } + var installed []AgentItem + for _, c := range candidates { + if _, err := exec.LookPath(c.Command); err == nil { + installed = append(installed, c) + } + } + return installed +} + // --- New workspace --------------------------------------------------------- func (m model) startNewWorkspace() (tea.Model, tea.Cmd) { @@ -59,10 +77,10 @@ func (m model) updateNewWorkspace(msg tea.KeyMsg) (tea.Model, tea.Cmd) { packs, _ := pack.List() switch len(packs) { case 0: - return m.createWorkspace(nil) + return m.nextAfterPack(nil) case 1: p := packs[0] - return m.createWorkspace(&p) + return m.nextAfterPack(&p) default: m.packList = packs m.packCursor = 0 @@ -85,11 +103,39 @@ func (m model) updateNewWorkspace(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.packCursor++ } case "enter": - if m.packCursor >= len(m.packList) { - return m.createWorkspace(nil) + var p *pack.Pack + if m.packCursor < len(m.packList) { + p = &m.packList[m.packCursor] } - p := m.packList[m.packCursor] - return m.createWorkspace(&p) + return m.nextAfterPack(p) + } + case wsStepAgent: + switch msg.String() { + case " ": + m.agentAutoApprove = !m.agentAutoApprove + case "esc": + if len(m.packList) > 1 { + m.wsStep = wsStepPack + } else { + m.wsStep = wsStepDir + } + case "up", "k": + if m.agentCursor > 0 { + m.agentCursor-- + } + case "down", "j": + if m.agentCursor < len(m.agentList) { + m.agentCursor++ + } + case "enter": + agentCmd := "" + if m.agentCursor < len(m.agentList) { + agentCmd = m.agentList[m.agentCursor].Command + if m.agentAutoApprove && m.agentList[m.agentCursor].AutoApproveFlag != "" { + agentCmd += " " + m.agentList[m.agentCursor].AutoApproveFlag + } + } + return m.createWorkspace(m.wsPack, agentCmd) } } return m, nil @@ -98,8 +144,21 @@ func (m model) updateNewWorkspace(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // createWorkspace runs init for the captured directory and pack, returning to // the dashboard with the new workspace selected. On failure it keeps the flow // open so the path can be corrected. -func (m model) createWorkspace(p *pack.Pack) (tea.Model, tea.Cmd) { - res, err := initcmd.Init(m.wsDir, p) +func (m model) nextAfterPack(p *pack.Pack) (tea.Model, tea.Cmd) { + m.wsPack = p + agents := getInstalledAgents() + if len(agents) == 0 { + return m.createWorkspace(p, "") + } + m.agentList = agents + m.agentCursor = 0 + m.agentAutoApprove = false + m.wsStep = wsStepAgent + return m, nil +} + +func (m model) createWorkspace(p *pack.Pack, agentCmd string) (tea.Model, tea.Cmd) { + res, err := initcmd.Init(m.wsDir, p, agentCmd) if err != nil { m.notice, m.noticeErr = err.Error(), true m.wsStep = wsStepDir @@ -118,6 +177,25 @@ func (m model) createWorkspace(p *pack.Pack) (tea.Model, tea.Cmd) { func (m model) viewNewWorkspace() string { switch m.wsStep { + case wsStepAgent: + var b strings.Builder + b.WriteString(metaStyle.Render(m.wsDir) + "\n\n") + b.WriteString("Choose an AI Agent:\n\n") + for i, a := range m.agentList { + b.WriteString(m.selectLine(i == m.agentCursor, a.Name)) + b.WriteString(" " + metaStyle.Render("Command: "+a.Command) + "\n") + } + b.WriteString(m.selectLine(m.agentCursor >= len(m.agentList), "Default global agent")) + + b.WriteString("\n\n") + if m.agentAutoApprove { + b.WriteString(okStyle.Render(" [x] Auto-approve ON")) + } else { + b.WriteString(metaStyle.Render(" [ ] Auto-approve OFF")) + } + + footer := actionKey("↑↓", "move") + " " + actionKey("space", "toggle auto-approve") + " " + actionKey("enter", "create") + " " + actionKey("esc", "back") + return m.frame("New workspace", b.String(), footer) case wsStepPack: var b strings.Builder b.WriteString(metaStyle.Render(m.wsDir) + "\n\n") @@ -253,50 +331,125 @@ func wrapMatches(matches []string, width int) string { // selectLine renders one selectable row with the accent bar when chosen. func (m model) selectLine(selected bool, label string) string { if selected { - return barStyle.Render("▌ ") + selNameStyle.Render(label) + return barStyle.Render("▌ ") + selNameStyle.Render(sanitizeRender(label)) } - return " " + nameStyle.Render(label) + return " " + nameStyle.Render(sanitizeRender(label)) } +type newTaskStep int + +const ( + newTaskStepName newTaskStep = iota + newTaskStepAgent +) + // --- New task -------------------------------------------------------------- func (m model) startNewTask(p project.Project) (tea.Model, tea.Cmd) { m.screen = scrNewTask m.formProj = p + m.newTaskStep = newTaskStepName + m.agentAutoApprove = false m.input = newTextInput("") m.notice = "" return m, nil } func (m model) updateNewTask(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "esc": - m.goDashboard() - case "enter": - name := strings.TrimSpace(m.input.Value()) - if name == "" { - return m, nil + switch m.newTaskStep { + case newTaskStepName: + switch msg.String() { + case "esc": + m.goDashboard() + case "enter": + name := strings.TrimSpace(m.input.Value()) + if name == "" { + return m, nil + } + if err := validateTaskName(name); err != nil { + m.notice, m.noticeErr = err.Error(), true + return m, nil + } + + agents := getInstalledAgents() + if len(agents) == 0 { + return m.finishNewTask(name, "") + } + m.newTaskName = name + m.agentList = agents + m.agentCursor = 0 + m.agentAutoApprove = false + m.newTaskStep = newTaskStepAgent + default: + m.input = m.input.update(msg) } - tk, err := createTask(m.formProj, name) - if err != nil { - m.notice, m.noticeErr = err.Error(), true - return m, nil + case newTaskStepAgent: + switch msg.String() { + case " ": + m.agentAutoApprove = !m.agentAutoApprove + case "esc": + m.newTaskStep = newTaskStepName + case "up", "k": + if m.agentCursor > 0 { + m.agentCursor-- + } + case "down", "j": + if m.agentCursor < len(m.agentList) { // Allow selecting "Default global agent" + m.agentCursor++ + } + case "enter": + agentCmd := "" + if m.agentCursor < len(m.agentList) { + agentCmd = m.agentList[m.agentCursor].Command + if m.agentAutoApprove && m.agentList[m.agentCursor].AutoApproveFlag != "" { + agentCmd += " " + m.agentList[m.agentCursor].AutoApproveFlag + } + } + return m.finishNewTask(m.newTaskName, agentCmd) } - proj := m.formProj - m.goDashboard() - m.refresh() - m.selectByName(proj.Name) - // Open the new task straight away — a parallel session without leaving Crabby. - return m, func() tea.Msg { return openTaskMsg{proj, tk} } - default: - m.input = m.input.update(msg) } return m, nil } +func (m model) finishNewTask(name, agentCmd string) (tea.Model, tea.Cmd) { + tk, err := createTask(m.formProj, name, agentCmd) + if err != nil { + m.notice, m.noticeErr = err.Error(), true + m.newTaskStep = newTaskStepName + return m, nil + } + proj := m.formProj + m.goDashboard() + m.refresh() + m.selectByName(proj.Name) + // Open the new task straight away — a parallel session without leaving Crabby. + return m, func() tea.Msg { return openTaskMsg{proj, tk} } +} + func (m model) viewNewTask() string { + if m.newTaskStep == newTaskStepAgent { + var b strings.Builder + b.WriteString(metaStyle.Render(fmt.Sprintf("New task %q in %q", m.newTaskName, m.formProj.Name)) + "\n\n") + b.WriteString("Choose an AI Agent:\n\n") + for i, a := range m.agentList { + b.WriteString(m.selectLine(i == m.agentCursor, a.Name)) + b.WriteString(" " + metaStyle.Render("Command: "+a.Command) + "\n") + } + b.WriteString(m.selectLine(m.agentCursor >= len(m.agentList), "Default global agent")) + + b.WriteString("\n\n") + if m.agentAutoApprove { + b.WriteString(okStyle.Render(" [x] Auto-approve ON")) + } else { + b.WriteString(metaStyle.Render(" [ ] Auto-approve OFF")) + } + + footer := actionKey("↑↓", "move") + " " + actionKey("space", "toggle auto-approve") + " " + actionKey("enter", "create & open") + " " + actionKey("esc", "back") + return m.frame("New task", b.String(), footer) + } + body := fmt.Sprintf("New task in %q.\n\nTask name:\n\n ", m.formProj.Name) + m.input.view("e.g. tests, docs, refactor") - footer := actionKey("enter", "create & open") + " " + actionKey("esc", "cancel") + footer := actionKey("enter", "continue") + " " + actionKey("esc", "cancel") return m.frame("New task", body, footer) } @@ -304,14 +457,15 @@ func (m model) viewNewTask() string { // createTask validates a name, builds its session, and registers it in the // workspace. It does not start the session — the caller opens it. -func createTask(p project.Project, name string) (project.Task, error) { +func createTask(p project.Project, name, agentCmd string) (project.Task, error) { if err := validateTaskName(name); err != nil { return project.Task{}, err } tk := project.Task{ - Name: name, - Session: session.TaskSession(p.Name, name), - Created: time.Now(), + Name: name, + Session: session.TaskSession(p.Name, name), + Created: time.Now(), + AgentCommand: agentCmd, } if err := project.AddTask(p.Name, tk); err != nil { return project.Task{}, fmt.Errorf("creating task %q: %w", name, err) diff --git a/internal/tui/forms_test.go b/internal/tui/forms_test.go index add509c..11c6c0a 100644 --- a/internal/tui/forms_test.go +++ b/internal/tui/forms_test.go @@ -4,10 +4,37 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/marioolf/crabby/internal/pack" + "github.com/marioolf/crabby/internal/project" ) +func setupMockPATH(t *testing.T, agents ...string) string { + dir := t.TempDir() + for _, name := range agents { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + origPATH := os.Getenv("PATH") + t.Setenv("PATH", dir+string(os.PathListSeparator)+origPATH) + return dir +} + +func setupTestEnv(t *testing.T) string { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("XDG_DATA_HOME", dir) + return dir +} + func TestDirCandidatesAndCompletion(t *testing.T) { + setupTestEnv(t) base := t.TempDir() for _, d := range []string{"alpha", "alps", ".hidden"} { if err := os.MkdirAll(filepath.Join(base, d), 0o755); err != nil { @@ -40,3 +67,459 @@ func TestDirCandidatesAndCompletion(t *testing.T) { t.Fatalf("complete (all) = %q, want %q", got, want) } } + +// --- R1.2 Workspace Agent Selection Unit Tests (T1 & T2) ------------------- + +func TestWorkspaceAgent_StepTransitionHappyPath(t *testing.T) { + setupTestEnv(t) + setupMockPATH(t, "claude", "opencode") + + m := baseModel() + res, _ := m.startNewWorkspace() + m = res.(model) + + targetDir := t.TempDir() + m.input = newTextInput(targetDir) + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + + if m.wsStep != wsStepAgent { + t.Fatalf("expected wsStepAgent, got step %v", m.wsStep) + } + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.screen != scrDashboard { + t.Fatalf("expected scrDashboard after workspace creation, got screen %v", m.screen) + } + if m.noticeErr { + t.Fatalf("workspace creation error notice: %s", m.notice) + } +} + +func TestWorkspaceAgent_AutoApproveToggleSpace(t *testing.T) { + setupTestEnv(t) + setupMockPATH(t, "claude") + + m := baseModel() + m.screen = scrNewWorkspace + m.wsStep = wsStepAgent + m.wsDir = t.TempDir() + m.agentList = []AgentItem{ + {Name: "Claude Code", Command: "claude", AutoApproveFlag: "--permission-mode auto"}, + } + m.agentCursor = 0 + m.agentAutoApprove = false + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) + if !m.agentAutoApprove { + t.Fatalf("expected agentAutoApprove to be true after Space key") + } + + mRes, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if mRes.noticeErr { + t.Fatalf("creation failed: %s", mRes.notice) + } + + projs, err := project.Load() + if err != nil || len(projs) == 0 { + t.Fatalf("failed loading projects: %v", err) + } + wantCmd := "claude --permission-mode auto" + lastProj := projs[len(projs)-1] + if lastProj.AgentCommand != wantCmd { + t.Fatalf("project AgentCommand = %q, want %q", lastProj.AgentCommand, wantCmd) + } +} + +func TestWorkspaceAgent_SelectDefaultGlobalAgent(t *testing.T) { + setupTestEnv(t) + setupMockPATH(t, "claude") + + m := baseModel() + m.screen = scrNewWorkspace + m.wsStep = wsStepAgent + m.wsDir = t.TempDir() + m.agentList = []AgentItem{ + {Name: "Claude Code", Command: "claude", AutoApproveFlag: "--permission-mode auto"}, + } + m.agentCursor = len(m.agentList) // Index for "Default global agent" + m.agentAutoApprove = true + + mRes, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if mRes.noticeErr { + t.Fatalf("workspace creation failed: %s", mRes.notice) + } + + projs, _ := project.Load() + if len(projs) == 0 { + t.Fatalf("no project registered") + } + lastProj := projs[len(projs)-1] + if lastProj.AgentCommand != "" { + t.Fatalf("AgentCommand = %q, want empty string", lastProj.AgentCommand) + } +} + +func TestWorkspaceAgent_NoPacksBypassesPackStep(t *testing.T) { + setupTestEnv(t) + setupMockPATH(t, "claude") + + m := baseModel() + res, _ := m.startNewWorkspace() + m = res.(model) + m.packList = nil // No packs available + + targetDir := t.TempDir() + m.input = newTextInput(targetDir) + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + + if m.wsStep != wsStepAgent { + t.Fatalf("expected step wsStepAgent when 0 packs available, got %v", m.wsStep) + } +} + +func TestWorkspaceAgent_EscNavigatesBackward(t *testing.T) { + setupTestEnv(t) + m := baseModel() + m.screen = scrNewWorkspace + m.wsStep = wsStepAgent + m.agentList = []AgentItem{{Name: "Claude", Command: "claude"}} + + // 1. With multiple packs -> Esc goes to wsStepPack + m.packList = []pack.Pack{{Name: "pack1"}, {Name: "pack2"}} + mEscPack, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEsc}) + if mEscPack.wsStep != wsStepPack { + t.Fatalf("expected wsStepPack after Esc, got %v", mEscPack.wsStep) + } + + // 2. With 0 packs -> Esc goes to wsStepDir + m.packList = nil + mEscDir, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEsc}) + if mEscDir.wsStep != wsStepDir { + t.Fatalf("expected wsStepDir after Esc, got %v", mEscDir.wsStep) + } +} + +func TestWorkspaceAgent_NoAgentsInstalledFallback(t *testing.T) { + setupTestEnv(t) + t.Setenv("PATH", t.TempDir()) // Empty PATH -> 0 agents installed + + m := baseModel() + res, _ := m.startNewWorkspace() + m = res.(model) + + targetDir := t.TempDir() + m.input = newTextInput(targetDir) + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + + if m.screen != scrDashboard { + t.Fatalf("expected scrDashboard after bypassing agent step with 0 installed agents, got screen %v, step %v", m.screen, m.wsStep) + } + if m.noticeErr { + t.Fatalf("workspace creation error: %s", m.notice) + } +} + +func TestWorkspaceAgent_SymlinkDirectoryHandling(t *testing.T) { + setupTestEnv(t) + + targetDir := t.TempDir() + realDir := t.TempDir() + symlinkPath := filepath.Join(targetDir, ".claude") + if err := os.Symlink(realDir, symlinkPath); err != nil { + t.Fatal(err) + } + + m := baseModel() + m.screen = scrNewWorkspace + m.wsStep = wsStepAgent + m.wsDir = targetDir + + mRes, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if !mRes.noticeErr { + t.Fatalf("expected notice error when .claude is a symlink") + } + if !strings.Contains(mRes.notice, "symlink") { + t.Fatalf("notice = %q, expected symlink error message", mRes.notice) + } +} + +func TestWorkspaceAgent_SpecialCharsInDirPath(t *testing.T) { + setupTestEnv(t) + setupMockPATH(t, "claude") + + targetDir := filepath.Join(t.TempDir(), "space test [v1] 🔥") + if err := os.MkdirAll(targetDir, 0o755); err != nil { + t.Fatal(err) + } + + m := baseModel() + m.screen = scrNewWorkspace + m.wsStep = wsStepAgent + m.wsDir = targetDir + m.agentList = []AgentItem{{Name: "Claude", Command: "claude"}} + + mRes, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if mRes.noticeErr { + t.Fatalf("workspace creation failed for path with special chars: %s", mRes.notice) + } + + projs, _ := project.Load() + found := false + for _, p := range projs { + if p.Path == targetDir { + found = true + break + } + } + if !found { + t.Fatalf("target path %q not found in registered projects: %v", targetDir, projs) + } +} + +func TestWorkspaceAgent_AgentListCursorWrapAround(t *testing.T) { + setupTestEnv(t) + m := baseModel() + m.screen = scrNewWorkspace + m.wsStep = wsStepAgent + m.agentList = []AgentItem{ + {Name: "Claude", Command: "claude"}, + {Name: "AGY", Command: "agy"}, + } + m.agentCursor = 0 + + for i := 0; i < 10; i++ { + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyDown}) + } + if m.agentCursor != len(m.agentList) { + t.Fatalf("agentCursor = %d, want %d", m.agentCursor, len(m.agentList)) + } +} + +func TestWorkspaceAgent_CreateWorkspaceFailureReset(t *testing.T) { + setupTestEnv(t) + + m := baseModel() + m.screen = scrNewWorkspace + m.wsStep = wsStepAgent + m.wsDir = filepath.Join(t.TempDir(), "non-existent-dir") + + mRes, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if !mRes.noticeErr { + t.Fatalf("expected noticeErr when creating workspace in non-existent directory") + } + if mRes.wsStep != wsStepDir { + t.Fatalf("expected wsStep reset to wsStepDir after failure, got %v", mRes.wsStep) + } +} + +// --- R1.3 Task Agent Selection Unit Tests (T1 & T2) ----------------------- + +func TestTaskAgent_StepTransitionHappyPath(t *testing.T) { + setupTestEnv(t) + setupMockPATH(t, "claude") + + p := project.Project{Name: "myproj", Path: t.TempDir()} + _ = project.Register(p) + + m := baseModel() + res, _ := m.startNewTask(p) + m = res.(model) + + m, _ = typeString(m, "feature-auth") + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + + if m.newTaskStep != newTaskStepAgent { + t.Fatalf("expected newTaskStepAgent, got step %v", m.newTaskStep) + } + + mRes, cmd := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if mRes.screen != scrDashboard { + t.Fatalf("expected scrDashboard after task creation, got %v", mRes.screen) + } + if cmd == nil { + t.Fatalf("expected openTaskMsg command returned") + } +} + +func TestTaskAgent_AgentWithAutoApproveFlag(t *testing.T) { + setupTestEnv(t) + setupMockPATH(t, "agy") + + p := project.Project{Name: "myproj", Path: t.TempDir()} + _ = project.Register(p) + + m := baseModel() + m.screen = scrNewTask + m.formProj = p + m.newTaskName = "db-migration" + m.newTaskStep = newTaskStepAgent + m.agentList = []AgentItem{ + {Name: "Antigravity CLI", Command: "agy", AutoApproveFlag: "--dangerously-skip-permissions"}, + } + m.agentCursor = 0 + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) + mRes, cmd := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + + if mRes.noticeErr { + t.Fatalf("task creation failed: %s", mRes.notice) + } + msg := cmd() + openMsg := msg.(openTaskMsg) + want := "agy --dangerously-skip-permissions" + if openMsg.task.AgentCommand != want { + t.Fatalf("task.AgentCommand = %q, want %q", openMsg.task.AgentCommand, want) + } +} + +func TestTaskAgent_SelectDefaultGlobalAgent(t *testing.T) { + setupTestEnv(t) + + p := project.Project{Name: "myproj", Path: t.TempDir()} + _ = project.Register(p) + + m := baseModel() + m.screen = scrNewTask + m.formProj = p + m.newTaskName = "clean-code" + m.newTaskStep = newTaskStepAgent + m.agentList = []AgentItem{ + {Name: "Claude Code", Command: "claude"}, + } + m.agentCursor = len(m.agentList) + + mRes, cmd := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if mRes.noticeErr { + t.Fatalf("task creation failed: %s", mRes.notice) + } + msg := cmd() + openMsg := msg.(openTaskMsg) + if openMsg.task.AgentCommand != "" { + t.Fatalf("task.AgentCommand = %q, want empty string", openMsg.task.AgentCommand) + } +} + +func TestTaskAgent_EscFromAgentReturnsToName(t *testing.T) { + setupTestEnv(t) + p := project.Project{Name: "myproj", Path: t.TempDir()} + + m := baseModel() + m.screen = scrNewTask + m.formProj = p + m.newTaskName = "my-feature" + m.newTaskStep = newTaskStepAgent + m.input = newTextInput("my-feature") + + mEsc, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEsc}) + if mEsc.newTaskStep != newTaskStepName { + t.Fatalf("expected newTaskStepName after Esc in agent step, got %v", mEsc.newTaskStep) + } + if mEsc.input.Value() != "my-feature" { + t.Fatalf("typed input = %q, want my-feature", mEsc.input.Value()) + } +} + +func TestTaskAgent_ValidationFailureStaysOnNameStep(t *testing.T) { + setupTestEnv(t) + p := project.Project{Name: "myproj", Path: t.TempDir()} + + m := baseModel() + res, _ := m.startNewTask(p) + m = res.(model) + + m, _ = typeString(m, "invalid/task@name") + mRes, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + + if !mRes.noticeErr { + t.Fatalf("expected notice error for invalid task name") + } + if mRes.newTaskStep != newTaskStepName { + t.Fatalf("expected step to remain newTaskStepName, got %v", mRes.newTaskStep) + } +} + +func TestTaskAgent_DuplicateTaskNameRejection(t *testing.T) { + setupTestEnv(t) + + p := project.Project{ + Name: "myproj", + Path: t.TempDir(), + Tasks: []project.Task{ + {Name: "existing-task", Session: "crabby_myproj_existing-task"}, + }, + } + _ = project.Register(p) + + m := baseModel() + m.screen = scrNewTask + m.formProj = p + m.newTaskName = "existing-task" + m.newTaskStep = newTaskStepAgent + + mRes, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if !mRes.noticeErr { + t.Fatalf("expected noticeErr when creating duplicate task name") + } + if !strings.Contains(mRes.notice, "already exists") { + t.Fatalf("notice = %q, expected 'already exists'", mRes.notice) + } + if mRes.newTaskStep != newTaskStepName { + t.Fatalf("expected reset to newTaskStepName, got %v", mRes.newTaskStep) + } +} + +func TestTaskAgent_SpecialCharactersInTaskName(t *testing.T) { + setupTestEnv(t) + invalidInputs := []string{"feat/login", "task@1", "my task", "task#name", "task!"} + for _, input := range invalidInputs { + t.Run("invalid_"+input, func(t *testing.T) { + if err := validateTaskName(input); err == nil { + t.Fatalf("expected validateTaskName(%q) to return error, got nil", input) + } + }) + } +} + +func TestTaskAgent_MaxLengthTaskName(t *testing.T) { + setupTestEnv(t) + longName := strings.Repeat("a", 256) + if err := validateTaskName(longName); err != nil { + t.Fatalf("expected 256-character valid name to pass validation, got err: %v", err) + } +} + +func TestTaskAgent_AgentAutoApproveToggleReset(t *testing.T) { + setupTestEnv(t) + setupMockPATH(t, "claude") + p := project.Project{Name: "myproj", Path: t.TempDir()} + + m := baseModel() + m.agentAutoApprove = true + res, _ := m.startNewTask(p) + mRes := res.(model) + + if mRes.agentAutoApprove { + t.Fatalf("expected agentAutoApprove to be reset to false when starting new task") + } +} + +func TestTaskAgent_ProjectWithNoExistingTasks(t *testing.T) { + setupTestEnv(t) + + p := project.Project{Name: "myproj", Path: t.TempDir(), Tasks: nil} + _ = project.Register(p) + + m := baseModel() + m.screen = scrNewTask + m.formProj = p + m.newTaskName = "first-task" + m.newTaskStep = newTaskStepAgent + + mRes, cmd := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if mRes.noticeErr { + t.Fatalf("createTask failed: %s", mRes.notice) + } + if cmd == nil { + t.Fatalf("expected openTaskMsg command") + } +} diff --git a/internal/tui/header.go b/internal/tui/header.go index 59226e7..c19ce89 100644 --- a/internal/tui/header.go +++ b/internal/tui/header.go @@ -17,11 +17,11 @@ var ( // Identity text, kept verbatim by design: // - the support line's separator is "·" (U+00B7) with surrounding spaces; -// - "claude" is intentionally lowercase in the support line. +// - "agents" is intentionally lowercase in the support line. const ( Wordmark = "CRABBY" - Tagline = "Many Claudes. One shell." - Subtitle = "prepare projects · orchestrate claude · one terminal" + Tagline = "Many Agents. One shell." + Subtitle = "prepare projects · orchestrate agents · one terminal" ) // crabArt is the crab in its resting pose: claws on top, then face, body, and diff --git a/internal/tui/header_test.go b/internal/tui/header_test.go index 8c93f9a..0273839 100644 --- a/internal/tui/header_test.go +++ b/internal/tui/header_test.go @@ -18,11 +18,11 @@ func TestIdentityText(t *testing.T) { if Wordmark != "CRABBY" { t.Errorf("Wordmark = %q", Wordmark) } - if Tagline != "Many Claudes. One shell." { + if Tagline != "Many Agents. One shell." { t.Errorf("Tagline = %q", Tagline) } - // "claude" is intentionally lowercase and the separator is U+00B7. - if Subtitle != "prepare projects · orchestrate claude · one terminal" { + // "agents" is intentionally lowercase and the separator is U+00B7. + if Subtitle != "prepare projects · orchestrate agents · one terminal" { t.Errorf("Subtitle = %q", Subtitle) } } diff --git a/internal/tui/help.go b/internal/tui/help.go index 13696f0..18bffc8 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -32,7 +32,7 @@ var helpSections = []helpSection{ {"esc", "step back / clear a message"}, }}, {"Workspaces & tasks", []helpEntry{ - {"enter", "open the selected task in Claude"}, + {"enter", "open the selected task in AI Agent"}, {"n", "new workspace (point it at a folder)"}, {"i", "import workspaces found under a folder"}, {"t", "new task in the selected workspace"}, @@ -73,6 +73,6 @@ func (m model) viewHelp() string { } } body := strings.TrimRight(b.String(), "\n") + "\n\n" + - metaStyle.Render("Inside a Claude session, press "+m.cfg.DetachKey+" to return here.") + metaStyle.Render("Inside a AI Agent session, press "+m.cfg.DetachKey+" to return here.") return m.frame("Help", body, actionKey("esc", "back")) } diff --git a/internal/tui/imports.go b/internal/tui/imports.go index fa44750..d5fb451 100644 --- a/internal/tui/imports.go +++ b/internal/tui/imports.go @@ -168,7 +168,7 @@ func (m model) performImport() (tea.Model, tea.Cmd) { if !m.importSel[i] || w.Imported { continue } - if _, err := initcmd.Init(w.Path, nil); err == nil { + if _, err := initcmd.Init(w.Path, nil, ""); err == nil { imported++ } } @@ -184,20 +184,20 @@ func (m model) performImport() (tea.Model, tea.Cmd) { func (m model) viewImport() string { if m.importStep == importStepScanning { - body := metaStyle.Render("Scanning your machine for Claude workspaces…") + "\n" + + body := metaStyle.Render("Scanning your machine for Agent workspaces…") + "\n" + metaStyle.Render("(your WSL home and the common project folders on your Windows drives)") return m.frame("Import workspaces", body, actionKey("esc", "cancel")) } var b strings.Builder if len(m.importList) == 0 { - b.WriteString(metaStyle.Render("No Claude workspaces found.") + "\n\n") + b.WriteString(metaStyle.Render("No Agent workspaces found.") + "\n\n") b.WriteString(metaStyle.Render("A workspace is any folder with a CLAUDE.md.") + "\n") footer := actionKey("r", "rescan") + " " + actionKey("esc", "back") return m.frame("Import workspaces", b.String(), footer) } - b.WriteString(headerStyle.Render(fmt.Sprintf("Found %s", plural(len(m.importList), "Claude workspace")))) + b.WriteString(headerStyle.Render(fmt.Sprintf("Found %s", plural(len(m.importList), "Agent workspace")))) b.WriteString(metaStyle.Render(fmt.Sprintf(" · Selected: %d", m.importSelectedCount()))) b.WriteString("\n\n") @@ -208,10 +208,10 @@ func (m model) viewImport() string { for i := start; i < end; i++ { w := m.importList[i] bar := " " - name := nameStyle.Render(w.Name) + name := nameStyle.Render(sanitizeRender(w.Name)) if i == m.importCursor { bar = barStyle.Render("▌ ") - name = selNameStyle.Render(w.Name) + name = selNameStyle.Render(sanitizeRender(w.Name)) } b.WriteString(bar + importCheckbox(w.Imported, m.importSel[i]) + " " + name + "\n") detail := w.Path diff --git a/internal/tui/mission.go b/internal/tui/mission.go index 8a1c00a..3e0bd65 100644 --- a/internal/tui/mission.go +++ b/internal/tui/mission.go @@ -14,7 +14,7 @@ import ( "github.com/marioolf/crabby/internal/session" ) -// Mission Control is a live overview of every Claude task at once: one card per +// Mission Control is a live overview of every AI Agent task at once: one card per // task, each showing its status and a short preview of what its tmux session is // currently showing. It is an observation surface — never a terminal emulator — // and is kept entirely separate from the dashboard's rendering. @@ -137,11 +137,11 @@ func (m *model) previewFor(name string, activity time.Time) []string { return lines } -// previewLines reduces a captured pane to its last few useful lines. Claude's +// previewLines reduces a captured pane to its last few useful lines. the Agent's // UI fills the bottom of the pane with its input box and a persistent status // line, so a naive "last N lines" shows only chrome; this strips box-drawing, // blank and status lines and keeps the most recent lines that actually carry -// words — the closest reliable signal to what Claude last said or is being asked. +// words — the closest reliable signal to what Agent last said or is being asked. func previewLines(raw string) []string { if raw == "" { return nil @@ -158,7 +158,7 @@ func previewLines(raw string) []string { return useful } -// boxRunes are the frame characters Claude (and other TUIs) draw around content. +// boxRunes are the frame characters Agent (and other TUIs) draw around content. const boxRunes = "─│╭╮╰╯┌┐└┘├┤┬┴┼═║╔╗╚╝▌▐▕▏" // trimBox strips surrounding box-drawing characters and spaces, leaving the @@ -170,7 +170,7 @@ func trimBox(s string) string { } // usefulLine reports whether a line carries real content rather than decoration -// or Claude's persistent status chrome. +// or the Agent's persistent status chrome. func usefulLine(t string) bool { if t == "" { return false @@ -240,7 +240,7 @@ func (m model) updateMission(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) viewMission() string { if len(m.mc.cards) == 0 { - body := metaStyle.Render("No Claude tasks yet.\nCreate one with t on the dashboard, then F12 to watch them here.") + body := metaStyle.Render("No AI Agent tasks yet.\nCreate one with t on the dashboard, then F12 to watch them here.") return m.frame("Mission Control", body, m.missionFooter()) } @@ -390,13 +390,13 @@ func cardStatus(c missionCard) (string, lipgloss.Style) { func cardMeta(c missionCard) string { var parts []string if c.branch != "" { - parts = append(parts, c.branch) + parts = append(parts, sanitizeRender(c.branch)) } if c.uptime > 0 { parts = append(parts, "up "+formatDuration(c.uptime)) } if c.insight.Model != "" { - parts = append(parts, c.insight.Model) + parts = append(parts, sanitizeRender(c.insight.Model)) } if c.insight.SessionTokens > 0 { parts = append(parts, formatTokens(c.insight.SessionTokens)+" tok") @@ -428,7 +428,7 @@ func withGaps(boxes []string) []string { // truncate shortens plain text to at most w display columns, marking the cut // with an ellipsis. It measures display width (not rune count), so wide glyphs -// that Claude prints — ✻, ※, box art — don't overflow the card and wrap. +// that Agent prints — ✻, ※, box art — don't overflow the card and wrap. func truncate(s string, w int) string { if w <= 0 { return "" diff --git a/internal/tui/packs.go b/internal/tui/packs.go index db49c82..d4d8dba 100644 --- a/internal/tui/packs.go +++ b/internal/tui/packs.go @@ -173,13 +173,13 @@ func (m model) viewPacks() string { b.WriteString(metaStyle.Render("No packs yet.") + "\n\n") } for i, p := range m.packs { - b.WriteString(m.selectLine(i == m.packsCursor && m.packAction == packActionNone, p.Name)) + b.WriteString(m.selectLine(i == m.packsCursor && m.packAction == packActionNone, sanitizeRender(p.Name))) if p.Version != "" { b.WriteString(metaStyle.Render(" v" + p.Version)) } b.WriteString("\n") if p.Description != "" { - b.WriteString(" " + metaStyle.Render(p.Description) + "\n") + b.WriteString(" " + metaStyle.Render(sanitizeRender(p.Description)) + "\n") } } b.WriteString("\n" + metaStyle.Render("packs live in "+pack.DisplayDir())) diff --git a/internal/tui/screens_test.go b/internal/tui/screens_test.go index a35c402..0b36ecb 100644 --- a/internal/tui/screens_test.go +++ b/internal/tui/screens_test.go @@ -1,6 +1,9 @@ package tui import ( + "fmt" + "os" + "path/filepath" "strings" "testing" "time" @@ -29,6 +32,25 @@ func baseModel() model { } } +// sendKey sends a single tea.KeyMsg to model.Update and returns the updated model and command. +func sendKey(m model, k tea.KeyMsg) (model, tea.Cmd) { + res, cmd := m.Update(k) + return res.(model), cmd +} + +// typeString simulates typing a full string char-by-char into the model's active input. +func typeString(m model, s string) (model, tea.Cmd) { + var lastCmd tea.Cmd + for _, r := range s { + var cmd tea.Cmd + m, cmd = sendKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + if cmd != nil { + lastCmd = cmd + } + } + return m, lastCmd +} + // TestEveryScreenRenders makes sure no screen's View panics, whatever sub-state // it is in — the failure mode most likely to slip past the compiler. func TestEveryScreenRenders(t *testing.T) { @@ -155,3 +177,731 @@ func TestF12TogglesMissionControl(t *testing.T) { t.Fatalf("F12 from mission -> screen %d, want dashboard", s) } } + +// --- R1.1 Model Msg Simulation (T1 & T2) ----------------------------------- + +func TestModelMsg_TextInputTypingAndDeleting(t *testing.T) { + inp := newTextInput("") + m := baseModel() + m.screen = scrNewWorkspace + m.wsStep = wsStepDir + m.input = inp + + m, _ = typeString(m, "abc d") + if m.input.Value() != "abc d" { + t.Fatalf("input value = %q, want 'abc d'", m.input.Value()) + } + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyBackspace}) + if m.input.Value() != "abc " { + t.Fatalf("after Backspace value = %q, want 'abc '", m.input.Value()) + } + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyCtrlU}) + if m.input.Value() != "" { + t.Fatalf("after Ctrl+U value = %q, want empty string", m.input.Value()) + } +} + +func TestModelMsg_DashboardKeyNavigation(t *testing.T) { + m := baseModel() + m.screen = scrDashboard + m.rows = []wsRow{ + { + proj: project.Project{Name: "p1", Path: "/tmp/p1"}, + tasks: []taskRow{ + {task: project.Task{Name: "t1"}}, + {task: project.Task{Name: "t2"}}, + }, + }, + { + proj: project.Project{Name: "p2", Path: "/tmp/p2"}, + }, + } + m.focus = paneWorkspaces + + if m.focus != paneWorkspaces { + t.Fatalf("initial focus = %v, want paneWorkspaces", m.focus) + } + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyDown}) + if m.wsCursor != 1 { + t.Fatalf("wsCursor = %d, want 1", m.wsCursor) + } + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyUp}) + if m.wsCursor != 0 { + t.Fatalf("wsCursor = %d, want 0", m.wsCursor) + } + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyTab}) + if m.focus != paneTasks { + t.Fatalf("focus after Tab = %v, want paneTasks", m.focus) + } + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyDown}) + if m.taskCursor != 1 { + t.Fatalf("taskCursor = %d, want 1", m.taskCursor) + } + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyShiftTab}) + if m.focus != paneWorkspaces { + t.Fatalf("focus after ShiftTab = %v, want paneWorkspaces", m.focus) + } +} + +func TestModelMsg_ScreenTransitions(t *testing.T) { + cases := []struct { + key string + want screen + }{ + {"n", scrNewWorkspace}, + {"i", scrImport}, + {"p", scrPacks}, + {"s", scrSettings}, + {"?", scrHelp}, + } + + for _, c := range cases { + t.Run("key_"+c.key, func(t *testing.T) { + m := baseModel() + m.screen = scrDashboard + mRes, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(c.key)}) + if mRes.screen != c.want { + t.Fatalf("key %q -> screen %v, want %v", c.key, mRes.screen, c.want) + } + }) + } +} + +func TestModelMsg_MissionControlToggle(t *testing.T) { + m := baseModel() + m.screen = scrDashboard + + m1, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyF12}) + if m1.screen != scrMission { + t.Fatalf("F12 from dashboard -> screen %v, want scrMission", m1.screen) + } + + m2, _ := sendKey(m1, tea.KeyMsg{Type: tea.KeyF12}) + if m2.screen != scrDashboard { + t.Fatalf("F12 from mission -> screen %v, want scrDashboard", m2.screen) + } +} + +func TestModelMsg_ConfirmationDialogDismissal(t *testing.T) { + m := baseModel() + m.screen = scrDashboard + m.rows = []wsRow{ + { + proj: project.Project{Name: "p1"}, + tasks: []taskRow{{task: project.Task{Name: "t1"}, state: session.Running}}, + }, + } + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + if m.confirm != confirmStop { + t.Fatalf("confirm = %v, want confirmStop", m.confirm) + } + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + if m.confirm != confirmNone { + t.Fatalf("confirm after 'n' = %v, want confirmNone", m.confirm) + } +} + +func TestModelMsg_ZeroDimensionsView(t *testing.T) { + screens := []screen{ + scrDashboard, scrMission, scrNewWorkspace, scrRelink, + scrImport, scrNewTask, scrPacks, scrSettings, scrHelp, + } + + for _, s := range screens { + t.Run("screen_"+fmt.Sprintf("%d", s), func(t *testing.T) { + m := baseModel() + m.width = 0 + m.height = 0 + m.screen = s + out := m.View() + _ = out + }) + } +} + +func TestModelMsg_CursorBoundaryClamping(t *testing.T) { + m := baseModel() + m.screen = scrDashboard + m.rows = []wsRow{{}, {}} + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyUp}) + if m.wsCursor != 0 { + t.Fatalf("wsCursor = %d, want 0", m.wsCursor) + } + + for i := 0; i < 50; i++ { + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyDown}) + } + if m.wsCursor != len(m.rows)-1 { + t.Fatalf("wsCursor = %d, want clamped max %d", m.wsCursor, len(m.rows)-1) + } +} + +func TestModelMsg_CtrlCQuitsFromAnyScreen(t *testing.T) { + screens := []screen{ + scrDashboard, scrMission, scrNewWorkspace, scrRelink, + scrImport, scrNewTask, scrPacks, scrSettings, scrHelp, + } + + for _, s := range screens { + t.Run("ctrl_c_screen_"+fmt.Sprintf("%d", s), func(t *testing.T) { + m := baseModel() + m.screen = s + _, cmd := sendKey(m, tea.KeyMsg{Type: tea.KeyCtrlC}) + if cmd == nil { + t.Fatalf("expected non-nil cmd for Ctrl+C on screen %v", s) + } + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Fatalf("cmd returned %T, want tea.QuitMsg", msg) + } + }) + } +} + +func TestModelMsg_EmptyInputSubmit(t *testing.T) { + m := baseModel() + m.screen = scrNewWorkspace + m.wsStep = wsStepDir + m.input = newTextInput("") + + mRes, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if mRes.wsStep != wsStepDir { + t.Fatalf("expected step to remain wsStepDir when submitting empty dir, got %v", mRes.wsStep) + } +} + +func TestModelMsg_RapidTabKeyPathCompletion(t *testing.T) { + m := baseModel() + m.screen = scrNewWorkspace + m.wsStep = wsStepDir + m.input = newTextInput("/nonexistent/path/xyz") + + for i := 0; i < 5; i++ { + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyTab}) + } + if m.input.Value() != "/nonexistent/path/xyz" { + t.Fatalf("input value = %q, want preserved original path", m.input.Value()) + } +} + +// --- R1.5 Agent Command Cascade Unit Tests (T1 & T2) ----------------------- + +func uniqueSessionName(t *testing.T) string { + clean := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + return '_' + }, t.Name()) + return fmt.Sprintf("sess_%s_%d", clean, time.Now().UnixNano()) +} + +func cleanupSession(t *testing.T, m *model, sess string) { + t.Cleanup(func() { + if sess != "" && m != nil && m.tmux.HasSession(sess) { + _ = m.tmux.KillSession(sess) + } + }) +} + +func TestAttachCascade_TaskCommandOverride(t *testing.T) { + sess := uniqueSessionName(t) + m := baseModel() + cleanupSession(t, &m, sess) + p := project.Project{Name: "p1", Path: t.TempDir(), AgentCommand: "missing-proj-cmd"} + tk := project.Task{Name: "t1", Session: sess, AgentCommand: "missing-task-cmd --auto"} + + resModel, _ := m.attach(p, tk) + res := resModel.(model) + if !res.noticeErr { + t.Fatalf("expected noticeErr=true for synthetic agent binary") + } + wantNotice := `Agent not found on PATH (looked for "missing-task-cmd --auto")` + if res.notice != wantNotice { + t.Fatalf("notice = %q, want %q", res.notice, wantNotice) + } +} + +func TestAttachCascade_ProjectCommandFallback(t *testing.T) { + sess := uniqueSessionName(t) + m := baseModel() + cleanupSession(t, &m, sess) + p := project.Project{Name: "p1", Path: t.TempDir(), AgentCommand: "missing-proj-cmd"} + tk := project.Task{Name: "t1", Session: sess, AgentCommand: ""} + + resModel, _ := m.attach(p, tk) + res := resModel.(model) + if !res.noticeErr { + t.Fatalf("expected noticeErr=true for synthetic agent binary") + } + wantNotice := `Agent not found on PATH (looked for "missing-proj-cmd")` + if res.notice != wantNotice { + t.Fatalf("notice = %q, want %q", res.notice, wantNotice) + } +} + +func TestAttachCascade_GlobalConfigFallback(t *testing.T) { + sess := uniqueSessionName(t) + m := baseModel() + cleanupSession(t, &m, sess) + m.cfg.ClaudeCommand = "missing-global-cmd" + p := project.Project{Name: "p1", Path: t.TempDir(), AgentCommand: ""} + tk := project.Task{Name: "t1", Session: sess, AgentCommand: ""} + + resModel, _ := m.attach(p, tk) + res := resModel.(model) + if !res.noticeErr { + t.Fatalf("expected noticeErr=true for synthetic agent binary") + } + wantNotice := `Agent not found on PATH (looked for "missing-global-cmd")` + if res.notice != wantNotice { + t.Fatalf("notice = %q, want %q", res.notice, wantNotice) + } +} + +func TestAttachCascade_ExecutableNotFoundNotice(t *testing.T) { + sess := uniqueSessionName(t) + m := baseModel() + cleanupSession(t, &m, sess) + p := project.Project{Name: "p1", Path: t.TempDir()} + tk := project.Task{Name: "t1", Session: sess, AgentCommand: "nonexistent-cmd"} + + resModel, _ := m.attach(p, tk) + res := resModel.(model) + if !res.noticeErr { + t.Fatalf("expected noticeErr=true") + } + if !strings.Contains(res.notice, "Agent not found on PATH") { + t.Fatalf("notice = %q, expected 'Agent not found on PATH'", res.notice) + } +} + +func TestAttachCascade_CommandWithArguments(t *testing.T) { + sess := uniqueSessionName(t) + m := baseModel() + cleanupSession(t, &m, sess) + p := project.Project{Name: "p1", Path: t.TempDir()} + tk := project.Task{Name: "t1", Session: sess, AgentCommand: "missing-aider-cmd --yes-always --dark-mode"} + + resModel, _ := m.attach(p, tk) + res := resModel.(model) + wantNotice := `Agent not found on PATH (looked for "missing-aider-cmd --yes-always --dark-mode")` + if res.notice != wantNotice { + t.Fatalf("notice = %q, want %q", res.notice, wantNotice) + } +} + +func TestAttachCascade_ExistingSessionBypassesLookPath(t *testing.T) { + sess := uniqueSessionName(t) + m := baseModel() + m.tmux = tmux.New("true") + cleanupSession(t, &m, sess) + + p := project.Project{Name: "p1", Path: t.TempDir()} + tk := project.Task{Name: "t1", Session: sess, AgentCommand: "missing-binary"} + + resModel, cmd := m.attach(p, tk) + res := resModel.(model) + if res.noticeErr { + t.Fatalf("expected existing session to attach without LookPath error, got notice: %s", res.notice) + } + if cmd == nil { + t.Fatalf("expected non-nil tea.Cmd for attaching existing session") + } +} + +func TestAttachCascade_EmptyGlobalConfigFallback(t *testing.T) { + sess := uniqueSessionName(t) + m := baseModel() + cleanupSession(t, &m, sess) + m.cfg.ClaudeCommand = "" + p := project.Project{Name: "p1", Path: t.TempDir(), AgentCommand: ""} + tk := project.Task{Name: "t1", Session: sess, AgentCommand: ""} + + resModel, _ := m.attach(p, tk) + res := resModel.(model) + if !res.noticeErr { + t.Fatalf("expected noticeErr for empty command") + } +} + +func TestAttachCascade_MultiWordCommandPath(t *testing.T) { + sess := uniqueSessionName(t) + m := baseModel() + cleanupSession(t, &m, sess) + p := project.Project{Name: "p1", Path: t.TempDir()} + tk := project.Task{Name: "t1", Session: sess, AgentCommand: "missing-claude-cmd --permission-mode auto"} + + resModel, _ := m.attach(p, tk) + res := resModel.(model) + wantNotice := `Agent not found on PATH (looked for "missing-claude-cmd --permission-mode auto")` + if res.notice != wantNotice { + t.Fatalf("notice = %q, want %q", res.notice, wantNotice) + } +} + +func TestAttachCascade_AfterAttachRestoreScreen(t *testing.T) { + sess := uniqueSessionName(t) + m := baseModel() + cleanupSession(t, &m, sess) + m.cfg.ClaudeCommand = "missing-claude-cmd" + m.screen = scrMission + p := project.Project{Name: "p1", Path: t.TempDir()} + tk := project.Task{Name: "t1", Session: sess} + + m.sessions = map[string]tmux.Session{sess: {Attached: true}} + + resModel, _ := m.attach(p, tk) + res := resModel.(model) + if res.afterAttach != scrMission { + t.Fatalf("afterAttach = %v, want scrMission", res.afterAttach) + } + + res2, _ := res.Update(execDoneMsg{err: nil}) + mBack := res2.(model) + if mBack.screen != scrMission { + t.Fatalf("screen after execDoneMsg = %v, want scrMission", mBack.screen) + } +} + +func TestAttachCascade_ExecDoneMsgWithError(t *testing.T) { + m := baseModel() + m.screen = scrDashboard + + resModel, _ := m.Update(execDoneMsg{err: fmt.Errorf("tmux disconnected")}) + res := resModel.(model) + if !res.noticeErr { + t.Fatalf("expected noticeErr=true after execDoneMsg error") + } + if !strings.Contains(res.notice, "session ended with an error: tmux disconnected") { + t.Fatalf("notice = %q, expected error notice", res.notice) + } +} + +// --- Tier 3: Pairwise Combinations (5 Tests) ------------------------------ + +func TestPairwise_WorkspaceAgentSelectionAndDisambiguation(t *testing.T) { + setupTestEnv(t) + setupMockPATH(t, "claude", "opencode") + + dir1 := filepath.Join(t.TempDir(), "path1", "mono") + dir2 := filepath.Join(t.TempDir(), "path2", "mono") + _ = os.MkdirAll(dir1, 0o755) + _ = os.MkdirAll(dir2, 0o755) + + m1 := baseModel() + m1.screen = scrNewWorkspace + m1.wsStep = wsStepAgent + m1.wsDir = dir1 + m1.agentList = []AgentItem{{Name: "Claude Code", Command: "claude"}} + m1.agentCursor = 0 + m1Res, _ := sendKey(m1, tea.KeyMsg{Type: tea.KeyEnter}) + if m1Res.noticeErr { + t.Fatalf("ws1 creation error: %s", m1Res.notice) + } + + m2 := baseModel() + m2.screen = scrNewWorkspace + m2.wsStep = wsStepAgent + m2.wsDir = dir2 + m2.agentList = []AgentItem{{Name: "OpenCode", Command: "opencode"}} + m2.agentCursor = 0 + m2Res, _ := sendKey(m2, tea.KeyMsg{Type: tea.KeyEnter}) + if m2Res.noticeErr { + t.Fatalf("ws2 creation error: %s", m2Res.notice) + } + + projs, _ := project.Load() + if len(projs) != 2 { + t.Fatalf("registered projects count = %d, want 2", len(projs)) + } + if projs[0].Name != "mono" { + t.Fatalf("projs[0].Name = %q, want mono", projs[0].Name) + } + if projs[1].Name != "mono-opencode" { + t.Fatalf("projs[1].Name = %q, want mono-opencode", projs[1].Name) + } +} + +func TestPairwise_TaskAgentOverrideAndAttachCascade(t *testing.T) { + sess := uniqueSessionName(t) + m := baseModel() + cleanupSession(t, &m, sess) + p := project.Project{Name: "p1", Path: t.TempDir(), AgentCommand: "missing-proj-cmd"} + tk := project.Task{Name: "t1", Session: sess, AgentCommand: "missing-task-override --auto"} + + resModel, _ := m.attach(p, tk) + res := resModel.(model) + if !strings.Contains(res.notice, "missing-task-override") { + t.Fatalf("attach cascade notice = %q, expected 'missing-task-override' from task override", res.notice) + } +} + +func TestPairwise_AutoApproveToggleAndTaskCreation(t *testing.T) { + setupTestEnv(t) + setupMockPATH(t, "claude") + + p := project.Project{Name: "myproj", Path: t.TempDir()} + _ = project.Register(p) + + m := baseModel() + m.screen = scrNewTask + m.formProj = p + m.newTaskName = "task-with-auto" + m.newTaskStep = newTaskStepAgent + m.agentList = []AgentItem{ + {Name: "Claude Code", Command: "claude", AutoApproveFlag: "--permission-mode auto"}, + } + m.agentCursor = 0 + m.agentAutoApprove = false + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) + mRes, cmd := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if mRes.noticeErr { + t.Fatalf("task creation failed: %s", mRes.notice) + } + + msg := cmd() + openMsg := msg.(openTaskMsg) + wantCmd := "claude --permission-mode auto" + if openMsg.task.AgentCommand != wantCmd { + t.Fatalf("openMsg.task.AgentCommand = %q, want %q", openMsg.task.AgentCommand, wantCmd) + } +} + +func TestPairwise_PackSelectionAndAgentSelection(t *testing.T) { + setupTestEnv(t) + setupMockPATH(t, "claude") + + targetDir := t.TempDir() + p := pack.Pack{ + Name: "go-pack", + Description: "Go Pack", + Dir: t.TempDir(), + } + + m := baseModel() + m.screen = scrNewWorkspace + m.wsStep = wsStepAgent + m.wsDir = targetDir + m.wsPack = &p + m.agentList = []AgentItem{{Name: "Claude", Command: "claude"}} + m.agentCursor = 0 + + mRes, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if mRes.noticeErr { + t.Fatalf("creation failed: %s", mRes.notice) + } + + projs, _ := project.Load() + if len(projs) != 1 { + t.Fatalf("projects len = %d, want 1", len(projs)) + } + if projs[0].AgentCommand != "claude" { + t.Fatalf("AgentCommand = %q, want claude", projs[0].AgentCommand) + } +} + +func TestPairwise_WorkspaceDisambiguationAndTaskSessionNaming(t *testing.T) { + setupTestEnv(t) + setupMockPATH(t, "claude", "opencode") + + dir1 := filepath.Join(t.TempDir(), "a", "app") + dir2 := filepath.Join(t.TempDir(), "b", "app") + _ = os.MkdirAll(dir1, 0o755) + _ = os.MkdirAll(dir2, 0o755) + + m1 := baseModel() + m1.screen = scrNewWorkspace + m1.wsStep = wsStepAgent + m1.wsDir = dir1 + m1.agentList = []AgentItem{{Name: "Claude", Command: "claude"}} + m1Res, _ := sendKey(m1, tea.KeyMsg{Type: tea.KeyEnter}) + if m1Res.noticeErr { + t.Fatalf("m1 creation error: %s", m1Res.notice) + } + + m2 := baseModel() + m2.screen = scrNewWorkspace + m2.wsStep = wsStepAgent + m2.wsDir = dir2 + m2.agentList = []AgentItem{{Name: "OpenCode", Command: "opencode"}} + m2Res, _ := sendKey(m2, tea.KeyMsg{Type: tea.KeyEnter}) + if m2Res.noticeErr { + t.Fatalf("m2 creation error: %s", m2Res.notice) + } + + projs, _ := project.Load() + if len(projs) < 2 { + t.Fatalf("expected 2 projects, got %d", len(projs)) + } + p2 := projs[1] + + mTask := baseModel() + mTask.screen = scrNewTask + mTask.formProj = p2 + mTask.newTaskName = "feature-x" + mTask.newTaskStep = newTaskStepAgent + mTask.agentList = []AgentItem{{Name: "OpenCode", Command: "opencode"}} + + _, cmd := sendKey(mTask, tea.KeyMsg{Type: tea.KeyEnter}) + msg := cmd() + openMsg := msg.(openTaskMsg) + + wantSession := "crabby_app-opencode_feature-x" + if openMsg.task.Session != wantSession { + t.Fatalf("task session = %q, want %q", openMsg.task.Session, wantSession) + } +} + +// --- Tier 4: Application Scenarios (4 Tests) ----------------------------- + +func TestScenario_CompleteWorkspaceAndMultiTaskLifecycle(t *testing.T) { + setupTestEnv(t) + setupMockPATH(t, "claude", "opencode") + + targetDir := filepath.Join(t.TempDir(), "monorepo") + _ = os.MkdirAll(targetDir, 0o755) + + m := baseModel() + m.screen = scrNewWorkspace + m.wsStep = wsStepAgent + m.wsDir = targetDir + m.agentList = []AgentItem{{Name: "Claude Code", Command: "claude"}} + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + + m.refresh() + if len(m.rows) != 1 { + t.Fatalf("expected 1 workspace row, got %d", len(m.rows)) + } + p := m.rows[0].proj + + mTask1 := baseModel() + mTask1.screen = scrNewTask + mTask1.formProj = p + mTask1.newTaskName = "task-one" + mTask1.newTaskStep = newTaskStepAgent + mTask1.agentList = []AgentItem{{Name: "Claude", Command: "claude"}} + mTask1.agentCursor = len(mTask1.agentList) + mTask1, _ = sendKey(mTask1, tea.KeyMsg{Type: tea.KeyEnter}) + + mTask2 := baseModel() + mTask2.screen = scrNewTask + mTask2.formProj = p + mTask2.newTaskName = "task-two" + mTask2.newTaskStep = newTaskStepAgent + mTask2.agentList = []AgentItem{{Name: "OpenCode", Command: "opencode", AutoApproveFlag: "--auto"}} + mTask2.agentCursor = 0 + mTask2, _ = sendKey(mTask2, tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) + mTask2, _ = sendKey(mTask2, tea.KeyMsg{Type: tea.KeyEnter}) + + projs, _ := project.Load() + if len(projs[0].Tasks) != 3 { + t.Fatalf("project tasks count = %d, want 3", len(projs[0].Tasks)) + } +} + +func TestScenario_RelinkAndAgentCascade(t *testing.T) { + setupTestEnv(t) + + oldDir := filepath.Join(t.TempDir(), "old-path") + newDir := filepath.Join(t.TempDir(), "new-path") + _ = os.MkdirAll(oldDir, 0o755) + _ = os.MkdirAll(newDir, 0o755) + + p := project.Project{Name: "myproj", Path: oldDir, AgentCommand: "claude"} + _ = project.Register(p) + + _ = os.RemoveAll(oldDir) + + m := baseModel() + m.screen = scrDashboard + m.refresh() + + m, _ = sendKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + if m.screen != scrRelink { + t.Fatalf("screen = %v, want scrRelink", m.screen) + } + + m.input = newTextInput(newDir) + mRes, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if mRes.screen != scrDashboard { + t.Fatalf("screen after relink submit = %v, want scrDashboard", mRes.screen) + } + + projs, _ := project.Load() + if projs[0].Path != newDir { + t.Fatalf("updated path = %q, want %q", projs[0].Path, newDir) + } +} + +func TestScenario_ImportWorkspacesAndAgentAssignment(t *testing.T) { + setupTestEnv(t) + + dir1 := filepath.Join(t.TempDir(), "discovered1") + dir2 := filepath.Join(t.TempDir(), "discovered2") + _ = os.MkdirAll(dir1, 0o755) + _ = os.MkdirAll(dir2, 0o755) + + m := baseModel() + m.screen = scrImport + m.importStep = importStepPick + m.importList = []importcmd.Workspace{ + {Name: "discovered1", Path: dir1}, + {Name: "discovered2", Path: dir2}, + } + m.importSel = []bool{true, false} + m.importCursor = 0 + + mRes, _ := sendKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if mRes.screen != scrDashboard { + t.Fatalf("screen after import = %v, want scrDashboard", mRes.screen) + } + + projs, _ := project.Load() + if len(projs) != 1 || projs[0].Name != "discovered1" { + t.Fatalf("imported projects = %v, want [discovered1]", projs) + } +} + +func TestScenario_MissionControlSessionMonitoringAndAttach(t *testing.T) { + setupTestEnv(t) + + sess := uniqueSessionName(t) + p := project.Project{Name: "myproj", Path: t.TempDir()} + tk := project.Task{Name: "active-task", Session: sess} + p.Tasks = []project.Task{tk} + _ = project.Register(p) + + m := baseModel() + m.tmux = tmux.New("true") + cleanupSession(t, &m, sess) + m.screen = scrMission + m.sessions = map[string]tmux.Session{ + sess: {Attached: true, Activity: time.Now()}, + } + + mResModel, _ := m.attach(p, tk) + mRes := mResModel.(model) + + if mRes.afterAttach != scrMission { + t.Fatalf("afterAttach = %v, want scrMission", mRes.afterAttach) + } + + mRestoredModel, _ := mRes.Update(execDoneMsg{err: nil}) + mRestored := mRestoredModel.(model) + + if mRestored.screen != scrMission { + t.Fatalf("restored screen = %v, want scrMission", mRestored.screen) + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 5fead42..aad6824 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -52,6 +52,22 @@ func center(width int, content string) string { return lipgloss.PlaceHorizontal(width, lipgloss.Center, content) } +// sanitizeRender strips ANSI/OSC escapes and control characters to prevent +// terminal injection when rendering untrusted names. +func sanitizeRender(s string) string { + var b strings.Builder + for _, r := range s { + if r == 0x1b || r == 0x7f { + continue + } + if (r >= 0x00 && r <= 0x1f && r != '\n' && r != '\t') || (r >= 0x80 && r <= 0x9f) { + continue + } + b.WriteRune(r) + } + return b.String() +} + // divider spans the banner's width so headers line up with the identity block. func divider() string { return dividerStyle.Render(strings.Repeat("─", BannerWidth())) } @@ -101,7 +117,7 @@ func workingActivity(ins insights.Insight) string { if ins.Detail == "" || ins.Detail == "Bash" { return "Running command" } - return "Running " + ins.Detail + return "Running " + sanitizeRender(ins.Detail) case insights.Responding: return "Responding" default: diff --git a/internal/tui/teatest_flows_test.go b/internal/tui/teatest_flows_test.go new file mode 100644 index 0000000..698fbd5 --- /dev/null +++ b/internal/tui/teatest_flows_test.go @@ -0,0 +1,779 @@ +package tui + +import ( + "os" + "path/filepath" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/marioolf/crabby/internal/importcmd" + "github.com/marioolf/crabby/internal/project" +) + +// --- Tier 1: Feature Coverage (R2 Integration Coverage) -------------------- + +func TestTeatest_Dashboard_Navigation(t *testing.T) { + h := newTestModelHarness(t) + projDir := filepath.Join(h.HomeDir, "proj-nav") + setupTestWorkspace(t, projDir) + + sendKeyRune(h.Tm, 'r') + waitForOutput(t, h.Tm, "proj-nav") + + sendTab(h.Tm) + time.Sleep(50 * time.Millisecond) + + sendShiftTab(h.Tm) + time.Sleep(50 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard, got %v", finalM.screen) + } + if finalM.focus != paneWorkspaces { + t.Fatalf("expected paneWorkspaces, got %v", finalM.focus) + } +} + +func TestTeatest_NewWorkspace_WizardFlow(t *testing.T) { + h := newTestModelHarness(t) + projDir := filepath.Join(h.HomeDir, "new-ws-target") + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatalf("failed to mkdir: %v", err) + } + + sendKeyRune(h.Tm, 'n') + waitForOutput(t, h.Tm, "New workspace") + + h.Tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU}) + h.Tm.Type(projDir) + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard, got %v", finalM.screen) + } + if len(finalM.rows) != 1 { + t.Fatalf("expected 1 workspace registered, got %d", len(finalM.rows)) + } + if finalM.rows[0].proj.Name != "new-ws-target" { + t.Fatalf("expected project name new-ws-target, got %s", finalM.rows[0].proj.Name) + } +} + +func TestTeatest_Import_DiscoveryAndPick(t *testing.T) { + h := newTestModelHarness(t) + dummyDir := filepath.Join(h.HomeDir, "discovered-proj") + if err := os.MkdirAll(dummyDir, 0o755); err != nil { + t.Fatalf("failed to mkdir: %v", err) + } + _ = os.WriteFile(filepath.Join(dummyDir, "CLAUDE.md"), []byte("# project"), 0o644) + + sendKeyRune(h.Tm, 'i') + time.Sleep(100 * time.Millisecond) + + h.Tm.Send(discoverDoneMsg{ + found: []importcmd.Workspace{{Name: "discovered-proj", Path: dummyDir, Branch: "main"}}, + err: nil, + }) + time.Sleep(100 * time.Millisecond) + + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard after import, got %v", finalM.screen) + } + if len(finalM.rows) != 1 { + t.Fatalf("expected 1 workspace imported, got %d", len(finalM.rows)) + } +} + +func TestTeatest_Relink_WorkspacePath(t *testing.T) { + h := newTestModelHarness(t) + oldDir := filepath.Join(h.HomeDir, "old-path") + newDir := filepath.Join(h.HomeDir, "relinked-path") + setupTestWorkspace(t, oldDir) + if err := os.MkdirAll(newDir, 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + + sendKeyRune(h.Tm, 'r') + waitForOutput(t, h.Tm, "old-path") + + sendKeyRune(h.Tm, 'e') + waitForOutput(t, h.Tm, "Point") + + h.Tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU}) + h.Tm.Type(newDir) + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard after relink, got %v", finalM.screen) + } + if len(finalM.rows) > 0 && finalM.rows[0].proj.Path != newDir { + t.Fatalf("expected relinked path %s, got %s", newDir, finalM.rows[0].proj.Path) + } +} + +func TestTeatest_NewTask_WizardAndAgentSelect(t *testing.T) { + h := newTestModelHarness(t) + projDir := filepath.Join(h.HomeDir, "task-proj") + setupTestWorkspace(t, projDir) + + sendKeyRune(h.Tm, 'r') + waitForOutput(t, h.Tm, "task-proj") + + sendKeyRune(h.Tm, 't') + waitForOutput(t, h.Tm, "New task") + + h.Tm.Type("feature-auth") + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + sendEnter(h.Tm) + waitForOutput(t, h.Tm, "Agent not found") + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard, got %v", finalM.screen) + } + projs, err := project.Load() + if err != nil || len(projs) == 0 { + t.Fatalf("failed to load projects: %v", err) + } + foundTask := false + for _, tk := range projs[0].Tasks { + if tk.Name == "feature-auth" { + foundTask = true + break + } + } + if !foundTask { + t.Fatalf("expected task 'feature-auth' to be created in project") + } +} + +func TestTeatest_Packs_CRUDOperations(t *testing.T) { + h := newTestModelHarness(t) + + sendKeyRune(h.Tm, 'p') + waitForOutput(t, h.Tm, "Packs") + + sendKeyRune(h.Tm, 'n') + h.Tm.Type("custom-pack") + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + sendKeyRune(h.Tm, 'c') + h.Tm.Type("custom-pack-copy") + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + sendKeyRune(h.Tm, 'd') + sendKeyRune(h.Tm, 'y') + time.Sleep(100 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.screen != scrPacks { + t.Fatalf("expected scrPacks, got %v", finalM.screen) + } +} + +func TestTeatest_Settings_DiagnosticsAndAbout(t *testing.T) { + h := newTestModelHarness(t) + + sendKeyRune(h.Tm, 's') + waitForOutput(t, h.Tm, "Settings") + + sendDown(h.Tm) + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + sendEsc(h.Tm) + time.Sleep(50 * time.Millisecond) + + sendDown(h.Tm) + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.settingsPane != settingsAbout { + t.Fatalf("expected settingsAbout pane, got %v", finalM.settingsPane) + } +} + +func TestTeatest_Help_ViewAndReturn(t *testing.T) { + h := newTestModelHarness(t) + + sendKeyRune(h.Tm, '?') + waitForOutput(t, h.Tm, "Help") + + sendEsc(h.Tm) + time.Sleep(50 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard after help, got %v", finalM.screen) + } +} + +func TestTeatest_MissionControl_GridAndCardSelection(t *testing.T) { + h := newTestModelHarness(t) + projDir := filepath.Join(h.HomeDir, "mc-proj") + p := setupTestWorkspace(t, projDir) + _, _ = createTask(p, "task-1", "") + + sendKeyRune(h.Tm, 'r') + sendF12(h.Tm) + waitForOutput(t, h.Tm, "Mission Control") + + sendKeyRune(h.Tm, 'l') + sendKeyRune(h.Tm, 'h') + sendKeyRune(h.Tm, 'j') + sendKeyRune(h.Tm, 'k') + + sendF12(h.Tm) + time.Sleep(50 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard after F12 toggle, got %v", finalM.screen) + } +} + +// --- Tier 2: Boundary & Corner Cases --------------------------------------- + +func TestTeatest_NewWorkspace_InvalidPath(t *testing.T) { + h := newTestModelHarness(t) + + sendKeyRune(h.Tm, 'n') + waitForOutput(t, h.Tm, "New workspace") + + h.Tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU}) + h.Tm.Type("/proc/sys/crabby_invalid_dir/path") + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + finalM := h.Finish(t) + if !finalM.noticeErr && finalM.wsStep != wsStepDir { + t.Fatalf("expected noticeErr or wsStepDir on invalid path, noticeErr=%v, wsStep=%v", finalM.noticeErr, finalM.wsStep) + } +} + +func TestTeatest_NewTask_InvalidNameAndCollision(t *testing.T) { + h := newTestModelHarness(t) + projDir := filepath.Join(h.HomeDir, "task-invalid-proj") + setupTestWorkspace(t, projDir) + + sendKeyRune(h.Tm, 'r') + sendKeyRune(h.Tm, 't') + waitForOutput(t, h.Tm, "New task") + + h.Tm.Type("invalid task!") + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + h.Tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU}) + h.Tm.Type("main") + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + finalM := h.Finish(t) + if !finalM.noticeErr { + t.Fatalf("expected noticeErr on invalid or duplicate task name") + } +} + +func TestTeatest_Wizard_EscCancellationAtAllSteps(t *testing.T) { + h := newTestModelHarness(t) + + sendKeyRune(h.Tm, 'n') + sendEsc(h.Tm) + time.Sleep(50 * time.Millisecond) + + sendKeyRune(h.Tm, 'p') + sendKeyRune(h.Tm, 'n') + sendEsc(h.Tm) + time.Sleep(50 * time.Millisecond) + sendEsc(h.Tm) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard after ESC cancellations, got %v", finalM.screen) + } +} + +func TestTeatest_Dashboard_EmptyStateAndMissingDir(t *testing.T) { + h := newTestModelHarness(t) + waitForOutput(t, h.Tm, "No workspaces yet.") + + missingDir := filepath.Join(h.HomeDir, "temp-missing-ws") + setupTestWorkspace(t, missingDir) + if err := os.RemoveAll(missingDir); err != nil { + t.Fatalf("failed to remove dir: %v", err) + } + + sendKeyRune(h.Tm, 'r') + time.Sleep(100 * time.Millisecond) + + sendKeyRune(h.Tm, 'o') + time.Sleep(50 * time.Millisecond) + + finalM := h.Finish(t) + if len(finalM.rows) > 0 && !finalM.rows[0].missing { + t.Fatalf("expected row.missing to be true for deleted directory") + } +} + +func TestTeatest_TerminalResize_BoundaryDimensions(t *testing.T) { + h := newTestModelHarness(t) + + sizes := []tea.WindowSizeMsg{ + {Width: 80, Height: 24}, + {Width: 200, Height: 60}, + {Width: 40, Height: 10}, + } + + for _, sz := range sizes { + h.Tm.Send(sz) + time.Sleep(50 * time.Millisecond) + } + + finalM := h.Finish(t) + view := finalM.View() + if view == "" { + t.Fatalf("expected non-empty view after terminal resizes") + } +} + +func TestTeatest_AgentSelection_NoInstalledAgentsFallback(t *testing.T) { + h := newTestModelHarness(t) + projDir := filepath.Join(h.HomeDir, "no-agent-ws") + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + + sendKeyRune(h.Tm, 'n') + waitForOutput(t, h.Tm, "New workspace") + + h.Tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU}) + h.Tm.Type(projDir) + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard after agent fallback, got %v", finalM.screen) + } +} + +func TestTeatest_Workspace_AgentSelectionWizard(t *testing.T) { + h := newTestModelHarness(t) + setupMockAgentBinary(t, h.HomeDir, "claude") + + projDir := filepath.Join(h.HomeDir, "agent-wizard-ws") + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + + sendKeyRune(h.Tm, 'n') + waitForOutput(t, h.Tm, "New workspace") + + h.Tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU}) + h.Tm.Type(projDir) + sendEnter(h.Tm) + waitForOutput(t, h.Tm, "Choose an AI Agent") + + // Toggle auto-approve flag using space + sendSpace(h.Tm) + time.Sleep(50 * time.Millisecond) + + // Press enter to create workspace with selected agent + sendEnter(h.Tm) + waitForOutput(t, h.Tm, "Created workspace") + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard, got %v", finalM.screen) + } + if len(finalM.rows) != 1 { + t.Fatalf("expected 1 workspace, got %d", len(finalM.rows)) + } + if finalM.rows[0].proj.AgentCommand != "claude --permission-mode auto" { + t.Fatalf("expected AgentCommand 'claude --permission-mode auto', got %q", finalM.rows[0].proj.AgentCommand) + } +} + +func TestTeatest_Task_AgentSelectionWizard(t *testing.T) { + h := newTestModelHarness(t) + setupMockAgentBinary(t, h.HomeDir, "claude") + + projDir := filepath.Join(h.HomeDir, "task-agent-wizard-ws") + p := setupTestWorkspace(t, projDir) + + sendKeyRune(h.Tm, 'r') + waitForOutput(t, h.Tm, p.Name) + + sendKeyRune(h.Tm, 't') + waitForOutput(t, h.Tm, "New task") + + h.Tm.Type("feature-billing") + sendEnter(h.Tm) + waitForOutput(t, h.Tm, "Choose an AI Agent") + + // Toggle auto-approve flag using space + sendSpace(h.Tm) + time.Sleep(50 * time.Millisecond) + + // Submit task creation + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard, got %v", finalM.screen) + } + + projs, err := project.Load() + if err != nil || len(projs) == 0 { + t.Fatalf("failed to load projects: %v", err) + } + var createdTask *project.Task + for _, tk := range projs[0].Tasks { + if tk.Name == "feature-billing" { + tCopy := tk + createdTask = &tCopy + break + } + } + if createdTask == nil { + t.Fatalf("expected task 'feature-billing' in project") + } + if createdTask.AgentCommand != "claude --permission-mode auto" { + t.Fatalf("expected task AgentCommand 'claude --permission-mode auto', got %q", createdTask.AgentCommand) + } +} + +func TestTeatest_Import_InteractiveCheckboxAndUnimport(t *testing.T) { + h := newTestModelHarness(t) + + importedDir := filepath.Join(h.HomeDir, "already-imported-ws") + p := setupTestWorkspace(t, importedDir) + + unimportedDir := filepath.Join(h.HomeDir, "unimported-ws") + if err := os.MkdirAll(unimportedDir, 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + _ = os.WriteFile(filepath.Join(unimportedDir, "CLAUDE.md"), []byte("# project"), 0o644) + + sendKeyRune(h.Tm, 'i') + time.Sleep(100 * time.Millisecond) + + // Send discoverDoneMsg with 1 imported and 1 unimported workspace + h.Tm.Send(discoverDoneMsg{ + found: []importcmd.Workspace{ + {Name: p.Name, Path: p.Path, Imported: true}, + {Name: "unimported-ws", Path: unimportedDir, Imported: false, Branch: "main"}, + }, + }) + time.Sleep(100 * time.Millisecond) + + // Test bulk deselect 'n' + sendKeyRune(h.Tm, 'n') + time.Sleep(50 * time.Millisecond) + + // Test bulk select 'a' + sendKeyRune(h.Tm, 'a') + time.Sleep(50 * time.Millisecond) + + // Test space key toggle on second row + sendDown(h.Tm) + sendSpace(h.Tm) + time.Sleep(50 * time.Millisecond) + + // Test un-importing 'd' on first row (imported) + sendUp(h.Tm) + sendKeyRune(h.Tm, 'd') + time.Sleep(100 * time.Millisecond) + + // Test rescan 'r' + sendKeyRune(h.Tm, 'r') + time.Sleep(100 * time.Millisecond) + + // Complete import + h.Tm.Send(discoverDoneMsg{ + found: []importcmd.Workspace{ + {Name: "unimported-ws", Path: unimportedDir, Imported: false}, + }, + }) + time.Sleep(100 * time.Millisecond) + sendKeyRune(h.Tm, 'i') + time.Sleep(100 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard after import completion, got %v", finalM.screen) + } +} + +// --- Tier 3: Pairwise Combinations ------------------------------------------ + +func TestTeatest_Pairwise_ScreenRoutingMatrix(t *testing.T) { + h := newTestModelHarness(t) + + sendKeyRune(h.Tm, 'p') + waitForOutput(t, h.Tm, "Packs") + + sendEsc(h.Tm) + sendKeyRune(h.Tm, 's') + waitForOutput(t, h.Tm, "Settings") + + sendEsc(h.Tm) + sendKeyRune(h.Tm, '?') + waitForOutput(t, h.Tm, "Help") + + sendEsc(h.Tm) + sendF12(h.Tm) + waitForOutput(t, h.Tm, "Mission Control") + + sendF12(h.Tm) + time.Sleep(50 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard after routing matrix, got %v", finalM.screen) + } +} + +func TestTeatest_Pairwise_F12MissionControlToggleFromNestedScreens(t *testing.T) { + h := newTestModelHarness(t) + + sendKeyRune(h.Tm, 'p') + sendF12(h.Tm) + sendF12(h.Tm) + sendEsc(h.Tm) + + sendKeyRune(h.Tm, 's') + sendF12(h.Tm) + sendF12(h.Tm) + sendEsc(h.Tm) + + sendKeyRune(h.Tm, '?') + sendF12(h.Tm) + sendF12(h.Tm) + sendEsc(h.Tm) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard after nested F12 toggles, got %v", finalM.screen) + } +} + +func TestTeatest_Pairwise_PaneFocusAndCursorMovement(t *testing.T) { + h := newTestModelHarness(t) + p1 := setupTestWorkspace(t, filepath.Join(h.HomeDir, "pw-ws1")) + p2 := setupTestWorkspace(t, filepath.Join(h.HomeDir, "pw-ws2")) + _, _ = createTask(p1, "t1-1", "") + _, _ = createTask(p1, "t1-2", "") + _, _ = createTask(p2, "t2-1", "") + + sendKeyRune(h.Tm, 'r') + time.Sleep(50 * time.Millisecond) + + sendDown(h.Tm) + sendTab(h.Tm) + sendDown(h.Tm) + sendUp(h.Tm) + sendShiftTab(h.Tm) + sendUp(h.Tm) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard, got %v", finalM.screen) + } +} + +func TestTeatest_Pairwise_MultiStepWizardBackAndForth(t *testing.T) { + h := newTestModelHarness(t) + projDir := filepath.Join(h.HomeDir, "wizard-backforth") + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + + sendKeyRune(h.Tm, 'n') + waitForOutput(t, h.Tm, "New workspace") + + h.Tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU}) + h.Tm.Type(projDir) + sendEnter(h.Tm) + time.Sleep(50 * time.Millisecond) + + sendEsc(h.Tm) + time.Sleep(50 * time.Millisecond) + + sendEsc(h.Tm) + time.Sleep(50 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard, got %v", finalM.screen) + } +} + +func TestTeatest_Pairwise_ConfirmDialogActions(t *testing.T) { + h := newTestModelHarness(t) + p := setupTestWorkspace(t, filepath.Join(h.HomeDir, "confirm-proj")) + _, _ = createTask(p, "task-del", "") + + sendKeyRune(h.Tm, 'r') + time.Sleep(50 * time.Millisecond) + + sendKeyRune(h.Tm, 'x') + sendKeyRune(h.Tm, 'n') + time.Sleep(50 * time.Millisecond) + + sendTab(h.Tm) + sendKeyRune(h.Tm, 'd') + sendKeyRune(h.Tm, 'n') + time.Sleep(50 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.confirm != confirmNone { + t.Fatalf("expected confirmNone after dialogue cancellations, got %v", finalM.confirm) + } +} + +// --- Tier 4: Real-World Workloads ------------------------------------------- + +func TestTeatest_Workload_FullProjectLifecycle(t *testing.T) { + h := newTestModelHarness(t) + + wsDir := filepath.Join(h.HomeDir, "lifecycle-backend") + if err := os.MkdirAll(wsDir, 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + sendKeyRune(h.Tm, 'n') + waitForOutput(t, h.Tm, "New workspace") + h.Tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU}) + h.Tm.Type(wsDir) + waitForOutput(t, h.Tm, "lifecycle-backend") + time.Sleep(50 * time.Millisecond) + + // wsStepDir -> wsStepPack + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + // wsStepPack -> wsStepAgent + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + // wsStepAgent -> createWorkspace -> scrDashboard + sendEnter(h.Tm) + waitForOutput(t, h.Tm, "Created workspace") + + // Task creation + sendKeyRune(h.Tm, 't') + waitForOutput(t, h.Tm, "New task") + h.Tm.Type("auth") + waitForOutput(t, h.Tm, "auth") + time.Sleep(50 * time.Millisecond) + + // newTaskStepName -> newTaskStepAgent + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + // newTaskStepAgent -> finishNewTask -> attach -> execDoneMsg -> scrDashboard + sendEnter(h.Tm) + waitForOutput(t, h.Tm, "Agent not found") + + // F12 Mission Control toggle back and forth + sendF12(h.Tm) + waitForOutput(t, h.Tm, "Mission Control") + sendF12(h.Tm) + waitForOutput(t, h.Tm, "Workspaces") + time.Sleep(100 * time.Millisecond) + + // Delete task (focus task column via Tab) + sendTab(h.Tm) + time.Sleep(50 * time.Millisecond) + sendKeyRune(h.Tm, 'd') + waitForOutput(t, h.Tm, "Remove task") + sendKeyRune(h.Tm, 'y') + time.Sleep(100 * time.Millisecond) + + // Delete workspace (focus workspace column via Shift+Tab) + sendShiftTab(h.Tm) + time.Sleep(50 * time.Millisecond) + sendKeyRune(h.Tm, 'd') + waitForOutput(t, h.Tm, "Remove workspace") + sendKeyRune(h.Tm, 'y') + time.Sleep(100 * time.Millisecond) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard after project lifecycle, got %v", finalM.screen) + } +} + +func TestTeatest_Workload_PackManagementAndRelinkFlow(t *testing.T) { + h := newTestModelHarness(t) + wsDir := filepath.Join(h.HomeDir, "workload-ws") + setupTestWorkspace(t, wsDir) + + sendKeyRune(h.Tm, 'p') + waitForOutput(t, h.Tm, "Packs") + + sendKeyRune(h.Tm, 'n') + h.Tm.Type("go-microservice") + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + sendKeyRune(h.Tm, 'c') + h.Tm.Type("go-microservice-v2") + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + sendEsc(h.Tm) + + sendKeyRune(h.Tm, 'r') + sendKeyRune(h.Tm, 'e') + waitForOutput(t, h.Tm, "Point") + + newPath := filepath.Join(h.HomeDir, "workload-ws-moved") + if err := os.MkdirAll(newPath, 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + h.Tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU}) + h.Tm.Type(newPath) + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + + sendKeyRune(h.Tm, 's') + waitForOutput(t, h.Tm, "Settings") + sendDown(h.Tm) + sendEnter(h.Tm) + time.Sleep(100 * time.Millisecond) + sendEsc(h.Tm) + sendEsc(h.Tm) + + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected scrDashboard at end of workload test, got %v", finalM.screen) + } +} diff --git a/internal/tui/teatest_harness_test.go b/internal/tui/teatest_harness_test.go new file mode 100644 index 0000000..4ef4e85 --- /dev/null +++ b/internal/tui/teatest_harness_test.go @@ -0,0 +1,212 @@ +package tui + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + + "github.com/marioolf/crabby/internal/config" + "github.com/marioolf/crabby/internal/initcmd" + "github.com/marioolf/crabby/internal/insights" + "github.com/marioolf/crabby/internal/project" + "github.com/marioolf/crabby/internal/tmux" +) + +type TestHarness struct { + Tm *teatest.TestModel + HomeDir string + DataDir string + stopped bool +} + +func newTestModelHarness(t *testing.T) *TestHarness { + t.Helper() + + tempDir := t.TempDir() + t.Setenv("HOME", tempDir) + t.Setenv("XDG_DATA_HOME", tempDir) + + // Isolate PATH so that teatest flows do not detect host agent binaries + // (like claude) and launch live host tmux sessions during testing. + binDir := filepath.Join(tempDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err == nil { + if tmuxPath, err := exec.LookPath("tmux"); err == nil { + _ = os.Symlink(tmuxPath, filepath.Join(binDir, "tmux")) + } + t.Setenv("PATH", binDir) + } + + dataDir := filepath.Join(tempDir, ".local", "share", "crabby") + configDir := filepath.Join(tempDir, ".config", "crabby") + if err := os.MkdirAll(dataDir, 0o700); err != nil { + t.Fatalf("failed to create isolated data dir: %v", err) + } + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatalf("failed to create isolated config dir: %v", err) + } + + cfg := config.Default() + cfg.ClaudeCommand = "missing-claude-cmd" + tClient := tmux.New("tmux") + coll := insights.New() + + m := model{ + cfg: cfg, + tmux: tClient, + insights: coll, + width: 120, + height: 40, + lastActivity: map[string]time.Time{}, + working: map[string]bool{}, + firstLoad: true, + } + m.refresh() + + tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(120, 40)) + tm.Send(tea.WindowSizeMsg{Width: 120, Height: 40}) + + h := &TestHarness{ + Tm: tm, + HomeDir: tempDir, + DataDir: dataDir, + } + // Stop the model before t.Setenv restores HOME and PATH. This prevents a + // late refresh from writing to the real user's registry after a failed test. + t.Cleanup(func() { h.stop(t) }) + return h +} + +func (h *TestHarness) stop(t *testing.T) { + t.Helper() + if h.stopped { + return + } + h.stopped = true + h.Tm.Quit() + h.Tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second)) +} + +func (h *TestHarness) Finish(t *testing.T) model { + t.Helper() + h.stop(t) + finalM, ok := h.Tm.FinalModel(t, teatest.WithFinalTimeout(2*time.Second)).(model) + if !ok { + return model{} + } + return finalM +} + +func sendKeyRune(tm *teatest.TestModel, r rune) { + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) +} + +func sendEnter(tm *teatest.TestModel) { + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) +} + +func sendEsc(tm *teatest.TestModel) { + tm.Send(tea.KeyMsg{Type: tea.KeyEsc}) +} + +func sendTab(tm *teatest.TestModel) { + tm.Send(tea.KeyMsg{Type: tea.KeyTab}) +} + +func sendShiftTab(tm *teatest.TestModel) { + tm.Send(tea.KeyMsg{Type: tea.KeyShiftTab}) +} + +func sendF12(tm *teatest.TestModel) { + tm.Send(tea.KeyMsg{Type: tea.KeyF12}) +} + +func sendSpace(tm *teatest.TestModel) { + tm.Send(tea.KeyMsg{Type: tea.KeySpace}) +} + +func sendUp(tm *teatest.TestModel) { + tm.Send(tea.KeyMsg{Type: tea.KeyUp}) +} + +func sendDown(tm *teatest.TestModel) { + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) +} + +func setupTestWorkspace(t *testing.T, dirPath string) project.Project { + t.Helper() + if err := os.MkdirAll(dirPath, 0o755); err != nil { + t.Fatalf("failed to create test workspace dir %s: %v", dirPath, err) + } + res, err := initcmd.Init(dirPath, nil, "") + if err != nil { + t.Fatalf("failed to init workspace at %s: %v", dirPath, err) + } + return res.Project +} + +func setupMockAgentBinary(t *testing.T, homeDir string, name string) { + t.Helper() + binDir := filepath.Join(homeDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("failed to create binDir: %v", err) + } + agentPath := filepath.Join(binDir, name) + script := "#!/bin/sh\nexit 0\n" + if err := os.WriteFile(agentPath, []byte(script), 0o755); err != nil { + t.Fatalf("failed to write mock agent script %s: %v", agentPath, err) + } +} + +func waitForOutput(t *testing.T, tm *teatest.TestModel, substr string) { + t.Helper() + teatest.WaitFor(t, tm.Output(), func(out []byte) bool { + return strings.Contains(string(out), substr) + }, teatest.WithDuration(2*time.Second)) +} + +func TestTeatestHarnessSanity(t *testing.T) { + h := newTestModelHarness(t) + waitForOutput(t, h.Tm, "CRABBY") + finalM := h.Finish(t) + if finalM.screen != scrDashboard { + t.Fatalf("expected screen scrDashboard, got %v", finalM.screen) + } +} + +func TestTeatest_GoroutineLeakVerification(t *testing.T) { + time.Sleep(1500 * time.Millisecond) + runtime.GC() + initialGoroutines := runtime.NumGoroutine() + + const iterations = 2 + for i := 0; i < iterations; i++ { + h := newTestModelHarness(t) + waitForOutput(t, h.Tm, "CRABBY") + sendKeyRune(h.Tm, '?') + waitForOutput(t, h.Tm, "Help") + sendEsc(h.Tm) + h.Finish(t) + } + + // Wait 2.5 seconds (longer than 1s refreshEach tick) and run GC + time.Sleep(2500 * time.Millisecond) + runtime.GC() + time.Sleep(200 * time.Millisecond) + + finalGoroutines := runtime.NumGoroutine() + buf := make([]byte, 64*1024) + n := runtime.Stack(buf, true) + t.Logf("REMAINING GOROUTINES AFTER 2.5s (%d vs initial %d):\n%s", finalGoroutines, initialGoroutines, string(buf[:n])) + + // Each teatest.NewTestModel creates 1 background signal listener (iterations = 2). + if finalGoroutines > initialGoroutines+iterations+1 { + t.Fatalf("possible goroutine leak detected: started with %d goroutines, ended with %d", initialGoroutines, finalGoroutines) + } +}