diff --git a/.gitignore b/.gitignore index 10ae83e7a..8afe11ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ !/browser/ !/musescore/ !/krita/ +!/iterm2/ # Step 5: Inside each software dir, ignore everything (including dotfiles) /gimp/* @@ -97,6 +98,8 @@ /musescore/.* /krita/* /krita/.* +/iterm2/* +/iterm2/.* # Step 6: ...except agent-harness/ !/gimp/agent-harness/ @@ -120,6 +123,7 @@ !/browser/agent-harness/ !/musescore/agent-harness/ !/krita/agent-harness/ +!/iterm2/agent-harness/ # Step 7: Ignore build artifacts within allowed dirs **/__pycache__/ diff --git a/README.md b/README.md index d747e268a..fc56f757a 100644 --- a/README.md +++ b/README.md @@ -449,7 +449,7 @@ The catalog auto-updates whenever `registry.json` changes โ€” new community CLIs | **๐Ÿ“‚ GitHub Repositories** | Transform any open-source project into agent-controllable tools through automatic CLI generation | VSCodium, WordPress, Calibre, Zotero, Joplin, Logseq, Penpot, Super Productivity | | **๐Ÿค– AI/ML Platforms** | Automate model training, inference pipelines, and hyperparameter tuning through structured commands | Stable Diffusion WebUI, ComfyUI, Ollama, InvokeAI, Text-generation-webui, Open WebUI, Fooocus, Kohya_ss, AnythingLLM, SillyTavern | | **๐Ÿ“Š Data & Analytics** | Enable programmatic data processing, visualization, and statistical analysis workflows | JupyterLab, Apache Superset, Metabase, Redash, DBeaver, KNIME, Orange, OpenSearch Dashboards, Lightdash | -| **๐Ÿ’ป Development Tools** | Streamline code editing, building, testing, and deployment processes via command interfaces | Jenkins, Gitea, Hoppscotch, Portainer, pgAdmin, SonarQube, ArgoCD, OpenLens, Insomnia, Beekeeper Studio | +| **๐Ÿ’ป Development Tools** | Streamline code editing, building, testing, and deployment processes via command interfaces | Jenkins, Gitea, Hoppscotch, Portainer, pgAdmin, SonarQube, ArgoCD, OpenLens, Insomnia, Beekeeper Studio, **[iTerm2](https://iterm2.com)** | | **๐ŸŽจ Creative & Media** | Control content creation, editing, and rendering workflows programmatically | Blender, GIMP, OBS Studio, Audacity, Krita, Kdenlive, Shotcut, Inkscape, Darktable, LMMS, Ardour | | **๐Ÿ”ฌ Scientific Computing** | Automate research workflows, simulations, and complex calculations | ImageJ, FreeCAD, QGIS, ParaView, Gephi, LibreCAD, Stellarium, KiCad, JASP, Jamovi | | **๐Ÿข Enterprise & Office** | Convert business applications and productivity tools into agent-accessible systems | NextCloud, GitLab, Grafana, Mattermost, LibreOffice, AppFlowy, NocoDB, Odoo (Community), Plane, ERPNext | diff --git a/iterm2/agent-harness/ITERM2.md b/iterm2/agent-harness/ITERM2.md new file mode 100644 index 000000000..0492bab01 --- /dev/null +++ b/iterm2/agent-harness/ITERM2.md @@ -0,0 +1,123 @@ +# iTerm2 CLI Harness โ€” SOP + +## Software Overview + +iTerm2 is a macOS terminal emulator with an extensive Python API that allows programmatic control of windows, tabs, sessions, profiles, arrangements, and more. The API communicates with the running iTerm2 process over a WebSocket connection at `ws://localhost:1912`. + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ cli-anything-iterm2 (Click) โ”‚ โ† This CLI harness +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ iterm2 Python API (async/websocket) +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ iTerm2.app (running macOS) โ”‚ โ† The real software +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Backend + +- **Real software**: iTerm2.app (must be running) +- **Python API**: `iterm2` package (`pip install iterm2`) +- **Connection**: WebSocket at `ws://localhost:1912` +- **Auth**: `ITERM2_COOKIE` and `ITERM2_KEY` env vars (auto-set by iTerm2 when running scripts) + +All iTerm2 API calls are async. The harness uses `iterm2.run_until_complete()` to bridge async operations into Click's synchronous command model. + +## Object Model + +``` +App +โ””โ”€โ”€ Window (one or more) + โ””โ”€โ”€ Tab (one or more per window) + โ””โ”€โ”€ Session (one or more per tab โ€” split panes) +``` + +- **Session**: The actual terminal emulator instance. Can send text, read screen, split into panes. +- **Tab**: A tab within a window. Contains one or more sessions (split panes). +- **Window**: A terminal window. Contains one or more tabs. +- **Profile**: A named configuration (colors, font, shell, etc.) +- **Arrangement**: A saved snapshot of all window/tab/session layout. + +## Command Groups + +| Group | Purpose | +|-------|---------| +| `app` | Workspace snapshot, app status, context management, app-level variables, modal dialogs, file panels | +| `window` | Create, list, close, resize, fullscreen, reposition windows | +| `tab` | Create, list, close, activate tabs; navigate split panes by direction | +| `session` | Send text, inject bytes, read screen/scrollback, split panes, shell integration, session variables | +| `profile` | List profiles, get profile details, list/apply color presets | +| `arrangement` | Save and restore complete window/tab/pane layouts | +| `tmux` | Full `tmux -CC` integration: bootstrap, connections, windows, send commands | +| `broadcast` | Sync keystrokes across multiple panes simultaneously | +| `menu` | Invoke any iTerm2 menu item programmatically | +| `pref` | Read/write global iTerm2 preferences; tmux integration settings | + +### Workspace Orientation + +Use `app snapshot` as the first command when landing in any existing workspace: + +```bash +cli-anything-iterm2 --json app snapshot +``` + +Returns for every session: name, current directory (`path`), foreground process, `user.role` label, and last visible output line โ€” a full picture without reading each pane's screen contents individually. + +Label panes on setup so snapshot can identify them on re-entry: +```bash +cli-anything-iterm2 session set-var user.role "api-server" +``` + +## Key API Patterns + +### Connecting and getting the app + +```python +import iterm2 +import asyncio + +async def main(connection): + app = await iterm2.async_get_app(connection) + windows = app.windows # List[Window] + +iterm2.run_until_complete(main) +``` + +### Sending text to a session + +```python +async def main(connection): + app = await iterm2.async_get_app(connection) + session = app.current_terminal_window.current_tab.current_session + await session.async_send_text("echo hello\n") +``` + +### Reading screen contents + +```python +async def main(connection): + app = await iterm2.async_get_app(connection) + session = app.current_terminal_window.current_tab.current_session + contents = await session.async_get_screen_contents() + for i in range(contents.number_of_lines): + line = contents.line(i) + print(line.string) +``` + +## Installation Prerequisites + +1. **macOS**: iTerm2 only runs on macOS +2. **iTerm2 app**: Must be installed and running +3. **Python API access**: Enable at iTerm2 โ†’ Preferences โ†’ API + +## Session State + +The CLI stores current context (window_id, tab_id, session_id) in a JSON session file at `~/.cli-anything-iterm2/session.json`. This allows stateful multi-command workflows without re-discovering the target on every call. + +## Error Handling + +- If iTerm2 is not running: clear error with instructions +- If object (window/tab/session) not found: list available IDs +- Connection failures: retry once, then fail with diagnostics diff --git a/iterm2/agent-harness/README.md b/iterm2/agent-harness/README.md new file mode 100644 index 000000000..202ae387a --- /dev/null +++ b/iterm2/agent-harness/README.md @@ -0,0 +1,370 @@ +# cli-anything-iterm2 + +A stateful CLI harness for [iTerm2](https://iterm2.com) โ€” gives AI agents (and humans) full programmatic control over a running iTerm2 instance from the command line. + +Part of the [CLI-Anything](https://github.com/HKUDS/CLI-Anything) ecosystem. + +--- + +## What it does + +iTerm2 has a powerful Python API, but using it directly requires writing async Python scripts for every operation. `cli-anything-iterm2` wraps the entire API into a clean, composable CLI with structured JSON output โ€” so agents can drive iTerm2 the same way a human would, without screenshots or UI automation. + +```bash +# Send a command to the focused terminal +cli-anything-iterm2 session send "git status" + +# Read what's on screen +cli-anything-iterm2 --json session screen + +# Split the pane and start a server in the new one +cli-anything-iterm2 session split --vertical --use-as-context +cli-anything-iterm2 session send "python3 -m http.server 8000" + +# Broadcast a keypress to every pane at once +cli-anything-iterm2 broadcast all-panes +cli-anything-iterm2 session send "clear" +``` + +--- + +## Prerequisites + +**1. macOS + iTerm2 running** +```bash +brew install --cask iterm2 +``` + +**2. Enable the Python API in iTerm2** +``` +iTerm2 โ†’ Preferences โ†’ General โ†’ Magic โ†’ Enable Python API โœ“ +``` + +**3. Python 3.10+** + +--- + +## Installation + +```bash +pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=iterm2/agent-harness +``` + +From source (for development): +```bash +git clone https://github.com/HKUDS/CLI-Anything.git +cd CLI-Anything/iterm2/agent-harness +pip install -e . +``` + +Verify: +```bash +cli-anything-iterm2 --help +``` + +--- + +## Command Groups + +| Group | What it controls | +|-------|-----------------| +| `app` | Workspace snapshot, app status, context management, app-level variables, modal dialogs, file panels | +| `window` | Create, list, close, resize, fullscreen windows | +| `tab` | Create, list, close, activate tabs; navigate split panes by direction | +| `session` | Send text, inject raw bytes, read screen, full scrollback, split panes, prompt detection | +| `profile` | List profiles, get profile details, list and apply color presets | +| `arrangement` | Save and restore complete window layouts | +| `tmux` | Full `tmux -CC` integration: bootstrap, connections, windows, send commands | +| `broadcast` | Sync keystrokes across multiple panes via broadcast domains | +| `menu` | Invoke any iTerm2 menu item programmatically | +| `pref` | Read and write global iTerm2 preferences; tmux integration settings | + +--- + +## Usage + +### Syntax + +```bash +cli-anything-iterm2 [--json] [OPTIONS] [ARGS] +``` + +Use `--json` for machine-readable output. Every command supports it. + +### App & context + +```bash +cli-anything-iterm2 --json app snapshot # rich workspace orientation: path, process, role, last output per pane +cli-anything-iterm2 app status # lightweight window/tab/session inventory +cli-anything-iterm2 app current # focus + save context +cli-anything-iterm2 app context # show saved context +cli-anything-iterm2 app get-var hostname # read app-level variable +``` + +### Windows + +```bash +cli-anything-iterm2 window list +cli-anything-iterm2 window create --profile "Default" +cli-anything-iterm2 window create --command "python3" +cli-anything-iterm2 window activate +cli-anything-iterm2 window set-title "My Window" +cli-anything-iterm2 window fullscreen on +cli-anything-iterm2 window frame # get/set x/y/w/h +cli-anything-iterm2 window close +``` + +### Tabs + +```bash +cli-anything-iterm2 tab list +cli-anything-iterm2 tab create --window-id +cli-anything-iterm2 tab activate +cli-anything-iterm2 tab close +``` + +### Sessions (panes) + +```bash +cli-anything-iterm2 session list +cli-anything-iterm2 session send "echo hello" # sends with newline +cli-anything-iterm2 session send "text" --no-newline +cli-anything-iterm2 session screen # visible terminal output +cli-anything-iterm2 session screen --lines 20 +cli-anything-iterm2 session scrollback --tail 200 # full scrollback history +cli-anything-iterm2 session scrollback --tail 200 --strip # strip ANSI codes +cli-anything-iterm2 session split # horizontal split +cli-anything-iterm2 session split --vertical # vertical split +cli-anything-iterm2 session split --vertical --use-as-context +cli-anything-iterm2 session set-name "API Worker" +cli-anything-iterm2 session restart +cli-anything-iterm2 session resize --columns 220 --rows 50 +cli-anything-iterm2 session selection # get selected text +cli-anything-iterm2 session get-var hostname # session variable +cli-anything-iterm2 session set-var user.project "myapp" +``` + +### Shell integration (requires iTerm2 Shell Integration installed) + +```bash +cli-anything-iterm2 session wait-prompt # block until shell prompt appears +cli-anything-iterm2 session wait-command-end # block until running command finishes +cli-anything-iterm2 session get-prompt # read last prompt string +``` + +### Profiles + +```bash +cli-anything-iterm2 profile list +cli-anything-iterm2 profile get "Default" +cli-anything-iterm2 profile color-presets +cli-anything-iterm2 profile apply-preset "Solarized Dark" +``` + +### Arrangements + +```bash +cli-anything-iterm2 arrangement list +cli-anything-iterm2 arrangement save "dev-env" +cli-anything-iterm2 arrangement restore "dev-env" +``` + +### tmux -CC integration + +```bash +cli-anything-iterm2 tmux bootstrap # start tmux -CC and wait for connection +cli-anything-iterm2 tmux list # list active tmux connections +cli-anything-iterm2 tmux tabs --connection-id # list tmux windows +cli-anything-iterm2 tmux send "ls -la" --connection-id +cli-anything-iterm2 tmux create-window --connection-id +cli-anything-iterm2 tmux set-visible +``` + +### Broadcast + +```bash +cli-anything-iterm2 broadcast list # list broadcast domains +cli-anything-iterm2 broadcast all-panes # broadcast to all panes in window +cli-anything-iterm2 broadcast add # add pane to broadcast domain +cli-anything-iterm2 broadcast clear # stop broadcasting +``` + +### Menu + +```bash +cli-anything-iterm2 menu list-common # show common menu actions +cli-anything-iterm2 menu select "Edit>Find>Find..." +cli-anything-iterm2 menu state "View>Show Tabs in Fullscreen" +``` + +### Preferences + +```bash +cli-anything-iterm2 pref get TabViewType +cli-anything-iterm2 pref set TabViewType 1 +cli-anything-iterm2 pref list # all valid preference keys +cli-anything-iterm2 pref tmux-get # tmux integration settings +cli-anything-iterm2 pref theme # current UI theme +``` + +--- + +## Stateful Context + +The CLI saves context (window_id, tab_id, session_id) to `~/.cli-anything-iterm2/session.json`. Once set with `app current`, subsequent commands target the same pane automatically โ€” no `--session-id` needed. + +```bash +# Set context once +cli-anything-iterm2 app current + +# All subsequent commands use it implicitly +cli-anything-iterm2 session send "git pull" +cli-anything-iterm2 --json session screen +cli-anything-iterm2 session split --vertical --use-as-context +cli-anything-iterm2 session send "npm run dev" +``` + +--- + +## Typical Agent Workflow + +```bash +# 1. Orient โ€” get every pane's name, path, process, role, and last output in one call +cli-anything-iterm2 --json app snapshot + +# 2. Lock onto the focused session +cli-anything-iterm2 app current + +# 3. Send a command and read the result +cli-anything-iterm2 session send "git log --oneline -10" +cli-anything-iterm2 --json session scrollback --tail 50 --strip + +# 4. Set up a multi-pane workspace โ€” label panes so snapshot identifies them later +cli-anything-iterm2 window create --profile "Default" +cli-anything-iterm2 app current +cli-anything-iterm2 session split --vertical --use-as-context +cli-anything-iterm2 session send "python3 -m http.server 8000" +cli-anything-iterm2 session set-var user.role "http-server" + +# 5. Wait for the server to start, then verify +cli-anything-iterm2 session wait-prompt +cli-anything-iterm2 --json session screen +``` + +--- + +## Interactive REPL + +Run without arguments to enter an interactive REPL that maintains context between commands: + +```bash +cli-anything-iterm2 +``` + +--- + +## Architecture + +``` +cli-anything-iterm2 (Click CLI) + โ”‚ + โ”‚ iterm2 Python package + โ”‚ asyncio + WebSocket (ws://localhost:1912) + โ–ผ +iTerm2.app โ† running macOS terminal emulator +``` + +All iTerm2 API calls are async. The harness uses `iterm2.run_until_complete()` to bridge async operations into Click's synchronous command model, so every command works identically in scripts, pipelines, and agent tool calls. + +The object model iTerm2 exposes: +``` +App +โ””โ”€โ”€ Window (one or more) + โ””โ”€โ”€ Tab (one or more per window) + โ””โ”€โ”€ Session (one or more per tab โ€” split panes) +``` + +--- + +## Session Variables + +iTerm2 sessions expose built-in variables you can read: + +```bash +cli-anything-iterm2 session get-var hostname # current host +cli-anything-iterm2 session get-var username # current user +cli-anything-iterm2 session get-var path # current directory +cli-anything-iterm2 session get-var pid # shell PID +``` + +Custom variables use a `user.` prefix: +```bash +cli-anything-iterm2 session set-var user.project "myapp" +cli-anything-iterm2 session get-var user.project +``` + +--- + +## Tests + +Unit tests run without iTerm2, E2E tests require a live instance. + +```bash +git clone https://github.com/HKUDS/CLI-Anything.git +cd CLI-Anything/iterm2/agent-harness +pip install -e . + +# Unit tests (no iTerm2 needed) +python3 -m pytest cli_anything/iterm2_ctl/tests/test_core.py -v + +# E2E tests (iTerm2 must be running) +python3 -m pytest cli_anything/iterm2_ctl/tests/test_full_e2e.py -v -s + +# Full suite +CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/iterm2_ctl/tests/ -v +``` + +| Suite | Requires | +|-------|---------| +| Unit | Nothing โ€” pure logic | +| E2E | iTerm2 running | +| tmux E2E | iTerm2 + active `tmux -CC` session | +| Subprocess | Installed `cli-anything-iterm2` command | + +--- + +## Project Structure + +``` +cli_anything/iterm2_ctl/ +โ”œโ”€โ”€ iterm2_ctl_cli.py # CLI entry point (Click groups + commands) +โ”œโ”€โ”€ core/ +โ”‚ โ”œโ”€โ”€ session.py # session send/screen/scrollback/split +โ”‚ โ”œโ”€โ”€ session_state.py # persistent context (window/tab/session IDs) +โ”‚ โ”œโ”€โ”€ window.py # window create/list/close/resize +โ”‚ โ”œโ”€โ”€ tab.py # tab create/list/close/activate +โ”‚ โ”œโ”€โ”€ profile.py # profile list/get/presets +โ”‚ โ”œโ”€โ”€ arrangement.py # save/restore window layouts +โ”‚ โ”œโ”€โ”€ tmux.py # tmux -CC integration +โ”‚ โ”œโ”€โ”€ broadcast.py # broadcast domains +โ”‚ โ”œโ”€โ”€ menu.py # menu item invocation +โ”‚ โ”œโ”€โ”€ pref.py # preferences read/write +โ”‚ โ”œโ”€โ”€ prompt.py # shell integration (wait-prompt etc.) +โ”‚ โ””โ”€โ”€ dialogs.py # modal dialogs + file panels +โ”œโ”€โ”€ utils/ +โ”‚ โ”œโ”€โ”€ iterm2_backend.py # connection helpers, error messages +โ”‚ โ””โ”€โ”€ repl_skin.py # interactive REPL skin +โ”œโ”€โ”€ skills/ +โ”‚ โ”œโ”€โ”€ SKILL.md # AI-discoverable skill definition +โ”‚ โ””โ”€โ”€ references/ # 12 narrow reference files for agents +โ””โ”€โ”€ tests/ + โ”œโ”€โ”€ test_core.py # unit tests + โ”œโ”€โ”€ test_full_e2e.py # E2E tests + โ””โ”€โ”€ TEST.md # test plan + results +``` + +--- + +## License + +MIT diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/__init__.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/__main__.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/__main__.py new file mode 100644 index 000000000..309085eac --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/__main__.py @@ -0,0 +1,5 @@ +"""Allow running as python3 -m cli_anything.iterm2_ctl""" +from cli_anything.iterm2_ctl.iterm2_ctl_cli import main + +if __name__ == "__main__": + main() diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/core/__init__.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/core/arrangement.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/arrangement.py new file mode 100644 index 000000000..d4be623cf --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/arrangement.py @@ -0,0 +1,76 @@ +"""Arrangement operations for iTerm2. + +Arrangements are saved snapshots of window/tab/session layouts. +All functions are async coroutines. +""" +from typing import Any, Dict, List, Optional + + +async def save_arrangement(connection, name: str) -> Dict[str, Any]: + """Save all current windows as a named arrangement. + + Replaces any existing arrangement with the same name. + + Args: + name: Name for the arrangement. + + Returns: + Dict confirming the save. + """ + import iterm2 + await iterm2.Arrangement.async_save(connection, name) + return {"name": name, "saved": True} + + +async def restore_arrangement( + connection, name: str, window_id: Optional[str] = None +) -> Dict[str, Any]: + """Restore a saved arrangement. + + Args: + name: Name of the arrangement to restore. + window_id: If provided, restore into an existing window. Otherwise opens new windows. + + Returns: + Dict confirming the restore. + """ + import iterm2 + await iterm2.Arrangement.async_restore(connection, name, window_id=window_id) + return {"name": name, "restored": True, "window_id": window_id} + + +async def list_arrangements(connection) -> List[str]: + """List all saved arrangement names.""" + import iterm2 + arrangements = await iterm2.Arrangement.async_list(connection) + return sorted(arrangements) + + +async def save_window_arrangement( + connection, window_id: str, name: str +) -> Dict[str, Any]: + """Save a single window as a named arrangement. + + Args: + window_id: The window to save. + name: Name for the arrangement. + """ + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_window + window = await async_find_window(connection, window_id) + await window.async_save_window_as_arrangement(name) + return {"window_id": window_id, "name": name, "saved": True} + + +async def restore_arrangement_in_window( + connection, window_id: str, name: str +) -> Dict[str, Any]: + """Restore a saved arrangement into an existing window. + + Args: + window_id: The window to restore into. + name: Name of the arrangement. + """ + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_window + window = await async_find_window(connection, window_id) + await window.async_restore_window_arrangement(name) + return {"window_id": window_id, "name": name, "restored": True} diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/core/broadcast.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/broadcast.py new file mode 100644 index 000000000..a9b0758c9 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/broadcast.py @@ -0,0 +1,165 @@ +"""Broadcast domain management for iTerm2. + +Broadcast domains control which sessions receive keyboard input simultaneously. +All sessions in the same domain receive every keystroke typed in any of them. + +All functions are async coroutines intended to be called via +cli_anything.iterm2_ctl.utils.iterm2_backend.run_iterm2(). +""" +from typing import Dict, List, Optional + + +async def get_broadcast_domains(connection) -> List[Dict]: + """Return current broadcast domains. + + Refreshes the domain list from iTerm2 before returning. + + Returns: + List of dicts, each with a 'sessions' key containing a list of + session_id strings belonging to that domain. + """ + import iterm2 + app = await iterm2.async_get_app(connection) + await app.async_refresh_broadcast_domains() + result = [] + for domain in app.broadcast_domains: + result.append({ + "sessions": [s.session_id for s in domain.sessions], + }) + return result + + +async def set_broadcast_domains(connection, domain_groups: List[List[str]]) -> Dict: + """Set broadcast domains from a list of session ID groups. + + Replaces all existing broadcast domains with the ones specified. + + Args: + domain_groups: e.g. [["sess1", "sess2"], ["sess3", "sess4"]]. + Each inner list becomes one BroadcastDomain. + Pass an empty list to clear all broadcasting. + + Returns: + Dict with 'domains' โ€” the resulting list of session ID groups. + """ + import iterm2 + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + + domains = [] + for group in domain_groups: + domain = iterm2.BroadcastDomain() + for session_id in group: + session = await async_find_session(connection, session_id) + domain.add_session(session) + domains.append(domain) + + await iterm2.async_set_broadcast_domains(connection, domains) + return { + "domains": domain_groups, + "domain_count": len(domains), + } + + +async def add_to_broadcast(connection, session_ids: List[str]) -> Dict: + """Add sessions to a single new broadcast domain. + + Creates a new broadcast domain containing exactly the given sessions. + Any existing domains are preserved alongside the new one. + + Args: + session_ids: List of session IDs to group into one broadcast domain. + + Returns: + Dict with the updated full domain list. + """ + import iterm2 + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + + app = await iterm2.async_get_app(connection) + await app.async_refresh_broadcast_domains() + + # Collect existing domains as lists of IDs + existing_groups = [ + [s.session_id for s in domain.sessions] + for domain in app.broadcast_domains + ] + + # Append new group + existing_groups.append(session_ids) + + # Rebuild and apply all domains + domains = [] + for group in existing_groups: + domain = iterm2.BroadcastDomain() + for sid in group: + session = await async_find_session(connection, sid) + domain.add_session(session) + domains.append(domain) + + await iterm2.async_set_broadcast_domains(connection, domains) + return { + "domains": existing_groups, + "domain_count": len(domains), + "added_sessions": session_ids, + } + + +async def clear_broadcast(connection) -> Dict: + """Clear all broadcast domains, stopping all input broadcasting. + + Returns: + Dict confirming the clear with 'domains' set to an empty list. + """ + import iterm2 + await iterm2.async_set_broadcast_domains(connection, []) + return { + "domains": [], + "domain_count": 0, + "cleared": True, + } + + +async def broadcast_all_panes( + connection, + window_id: Optional[str] = None, +) -> Dict: + """Add all sessions in a window (or all windows) to a single broadcast domain. + + Args: + window_id: If given, only collect sessions from that window. + If None, collect sessions from every window. + + Returns: + Dict with the session IDs added to the domain. + """ + import iterm2 + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + + app = await iterm2.async_get_app(connection) + session_ids = [] + + for window in app.windows: + if window_id is not None and window.window_id != window_id: + continue + for tab in window.tabs: + for session in tab.sessions: + session_ids.append(session.session_id) + + if not session_ids: + raise ValueError( + f"No sessions found" + + (f" in window '{window_id}'" if window_id else "") + ) + + domain = iterm2.BroadcastDomain() + for sid in session_ids: + session = await async_find_session(connection, sid) + domain.add_session(session) + + await iterm2.async_set_broadcast_domains(connection, [domain]) + return { + "domains": [session_ids], + "domain_count": 1, + "session_count": len(session_ids), + "window_id": window_id, + } diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/core/dialogs.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/dialogs.py new file mode 100644 index 000000000..57ae69480 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/dialogs.py @@ -0,0 +1,150 @@ +"""Dialog and panel operations for iTerm2. + +Covers modal alerts, text-input dialogs, and file open/save panels. +All functions are async coroutines. +""" +from typing import Any, Dict, List, Optional + + +async def show_alert( + connection, + title: str, + subtitle: str, + buttons: Optional[List[str]] = None, + window_id: Optional[str] = None, +) -> Dict[str, Any]: + """Show a modal alert dialog with optional buttons. + + Args: + title: Bold title text shown at the top. + subtitle: Informative text body (may be multi-line). + buttons: List of button labels. Defaults to ["OK"] if empty. + window_id: Attach to a window (None = application-modal). + + Returns: + Dict with 'button_index' (1000-based) and 'button_label'. + """ + import iterm2 + alert = iterm2.Alert(title, subtitle, window_id=window_id) + if buttons: + for b in buttons: + alert.add_button(b) + index = await alert.async_run(connection) + # button_index is 1000-based; map back to 0-based label + label = None + if buttons: + zero_based = index - 1000 + label = buttons[zero_based] if 0 <= zero_based < len(buttons) else None + else: + label = "OK" + return { + "button_index": index, + "button_label": label, + } + + +async def show_text_input( + connection, + title: str, + subtitle: str, + placeholder: str = "", + default_value: str = "", + window_id: Optional[str] = None, +) -> Dict[str, Any]: + """Show a modal alert with a text input field. + + Args: + title: Bold title text. + subtitle: Informative text body. + placeholder: Gray placeholder text in the input field. + default_value: Pre-filled text value. + window_id: Attach to a window (None = application-modal). + + Returns: + Dict with 'text' (the entered string) or 'cancelled' (bool). + """ + import iterm2 + alert = iterm2.TextInputAlert( + title, + subtitle, + placeholder, + default_value, + window_id=window_id, + ) + result = await alert.async_run(connection) + if result is None: + return {"cancelled": True, "text": None} + return {"cancelled": False, "text": result} + + +async def show_open_panel( + connection, + title: str = "Open", + path: Optional[str] = None, + extensions: Optional[List[str]] = None, + can_choose_directories: bool = False, + allows_multiple: bool = False, +) -> Dict[str, Any]: + """Show a macOS Open File panel and return the chosen path(s). + + Args: + title: Panel message text (shown as title). + path: Initial directory to open. + extensions: List of allowed file extensions, e.g. ["py", "txt"]. + can_choose_directories: Allow selecting directories. + allows_multiple: Allow selecting multiple files. + + Returns: + Dict with 'files' list of chosen paths, or 'cancelled' if dismissed. + """ + import iterm2 + panel = iterm2.OpenPanel() + if path: + panel.path = path + if extensions: + panel.extensions = extensions + if title: + panel.message = title + + options = [iterm2.OpenPanel.Options.CAN_CHOOSE_FILES] + if can_choose_directories: + options.append(iterm2.OpenPanel.Options.CAN_CHOOSE_DIRECTORIES) + if allows_multiple: + options.append(iterm2.OpenPanel.Options.ALLOWS_MULTIPLE_SELECTION) + panel.options = options + + result = await panel.async_run(connection) + if result is None: + return {"cancelled": True, "files": []} + return {"cancelled": False, "files": result.files} + + +async def show_save_panel( + connection, + title: str = "Save", + path: Optional[str] = None, + filename: Optional[str] = None, +) -> Dict[str, Any]: + """Show a macOS Save File panel and return the chosen save path. + + Args: + title: Panel message text. + path: Initial directory. + filename: Pre-filled filename. + + Returns: + Dict with 'file' (chosen path) or 'cancelled' (bool). + """ + import iterm2 + panel = iterm2.SavePanel() + if path: + panel.path = path + if filename: + panel.filename = filename + if title: + panel.message = title + + result = await panel.async_run(connection) + if result is None: + return {"cancelled": True, "file": None} + return {"cancelled": False, "file": result.filename} diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/core/menu.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/menu.py new file mode 100644 index 000000000..b3ae72f47 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/menu.py @@ -0,0 +1,156 @@ +"""Main menu invocation for iTerm2. + +Allows invoking any iTerm2 menu item by its identifier string. + +All functions are async coroutines intended to be called via +cli_anything.iterm2_ctl.utils.iterm2_backend.run_iterm2(). +""" +from typing import Dict, List + + +async def select_menu_item(connection, identifier: str) -> Dict: + """Invoke a menu item by its identifier string. + + Args: + identifier: The menu item identifier, e.g. + "New Window", "Shell/Split Vertically with Current Profile", + "View/Show Tabs in Fullscreen". Use list_common_menu_items() + for a reference list of available identifiers. + + Returns: + Dict with 'identifier' and 'invoked': True. + """ + import iterm2 + await iterm2.MainMenu.async_select_menu_item(connection, identifier) + return { + "identifier": identifier, + "invoked": True, + } + + +async def get_menu_item_state(connection, identifier: str) -> Dict: + """Get the state (checked, enabled) of a menu item. + + Args: + identifier: The menu item identifier string. + + Returns: + Dict with 'identifier', 'checked' (bool), and 'enabled' (bool). + """ + import iterm2 + state = await iterm2.MainMenu.async_get_menu_item_state(connection, identifier) + return { + "identifier": identifier, + "checked": state.checked, + "enabled": state.enabled, + } + + +async def list_common_menu_items(connection) -> List[Dict]: + """Return a curated reference list of useful menu item identifiers. + + Does not query iTerm2 โ€” returns a hardcoded list of the most commonly + useful identifiers drawn from the MainMenu enum. + + Returns: + List of dicts, each with 'identifier' and 'description' keys. + """ + return [ + # iTerm2 application menu + { + "identifier": "iTerm2/Preferences...", + "description": "Open iTerm2 Preferences window", + }, + { + "identifier": "iTerm2/Toggle Debug Logging", + "description": "Toggle debug logging on/off", + }, + # Shell menu โ€” window / session creation + { + "identifier": "Shell/New Window", + "description": "Open a new iTerm2 window", + }, + { + "identifier": "Shell/New Window with Current Profile", + "description": "Open a new window using the current profile", + }, + { + "identifier": "Shell/New Tab", + "description": "Open a new tab in the current window", + }, + { + "identifier": "Shell/New Tab with Current Profile", + "description": "Open a new tab using the current profile", + }, + { + "identifier": "Shell/Split Vertically with Current Profile", + "description": "Split the current pane vertically (side by side)", + }, + { + "identifier": "Shell/Split Horizontally with Current Profile", + "description": "Split the current pane horizontally (top/bottom)", + }, + { + "identifier": "Shell/Close", + "description": "Close the current session/pane", + }, + { + "identifier": "Shell/Close Window", + "description": "Close the current window", + }, + # View menu + { + "identifier": "View/Show Tabs in Fullscreen", + "description": "Show the tab bar when in fullscreen mode", + }, + { + "identifier": "View/Hide Tab Bar", + "description": "Toggle the tab bar visibility", + }, + { + "identifier": "View/Enter Full Screen", + "description": "Enter fullscreen mode", + }, + { + "identifier": "View/Exit Full Screen", + "description": "Exit fullscreen mode", + }, + { + "identifier": "View/Show/Hide Command History", + "description": "Toggle the command history tool", + }, + { + "identifier": "View/Show/Hide Recent Directories", + "description": "Toggle the recent directories tool", + }, + # Edit / Find + { + "identifier": "Find/Find...", + "description": "Open the find bar in the current session", + }, + { + "identifier": "Find/Find Next", + "description": "Find next match", + }, + { + "identifier": "Find/Find Previous", + "description": "Find previous match", + }, + # Window menu + { + "identifier": "Window/Minimize", + "description": "Minimize the current window", + }, + { + "identifier": "Window/Zoom", + "description": "Zoom the current window", + }, + { + "identifier": "Window/Arrange Windows Horizontally", + "description": "Tile all iTerm2 windows horizontally", + }, + { + "identifier": "Window/Bring All to Front", + "description": "Bring all iTerm2 windows to the front", + }, + ] diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/core/pref.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/pref.py new file mode 100644 index 000000000..0f8341207 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/pref.py @@ -0,0 +1,182 @@ +"""Global preferences management for iTerm2. + +Read and write any of iTerm2's global preferences via the Python API. +Includes curated tmux-specific preference helpers. + +All functions are async coroutines intended to be called via +cli_anything.iterm2_ctl.utils.iterm2_backend.run_iterm2(). +""" +from typing import Any, Dict + + +def _parse_value(value: str) -> Any: + """Parse a string value into an appropriate Python type. + + Converts "true"/"false" (case-insensitive) to bool, numeric strings to + int or float, and leaves everything else as a string. + """ + if isinstance(value, str): + lower = value.lower() + if lower == "true": + return True + if lower == "false": + return False + try: + as_int = int(value) + return as_int + except ValueError: + pass + try: + as_float = float(value) + return as_float + except ValueError: + pass + return value + + +async def get_preference(connection, key: str) -> Dict: + """Get a global iTerm2 preference by key. + + Args: + key: Either a PreferenceKey enum member name (e.g. "OPEN_TMUX_WINDOWS_IN") + or a raw preference key string (e.g. "OpenTmuxWindowsIn"). + + Returns: + Dict with 'key' and 'value'. + """ + import iterm2 + + # Try to resolve as a PreferenceKey enum name first + pref_key = key + try: + pref_key = iterm2.PreferenceKey[key] + except (KeyError, AttributeError): + pass # Fall through and use the raw string + + value = await iterm2.async_get_preference(connection, pref_key) + return { + "key": key, + "value": value, + } + + +async def set_preference(connection, key: str, value: Any) -> Dict: + """Set a global iTerm2 preference. + + Args: + key: Either a PreferenceKey enum member name or a raw preference key string. + value: The value to set. Strings are parsed: "true"/"false" -> bool, + numeric strings -> int/float, all others remain strings. + + Returns: + Dict with 'key', 'value', and 'set': True. + """ + import iterm2 + + parsed = _parse_value(value) if isinstance(value, str) else value + + pref_key = key + try: + pref_key = iterm2.PreferenceKey[key] + except (KeyError, AttributeError): + pass + + await iterm2.async_set_preference(connection, pref_key, parsed) + return { + "key": key, + "value": parsed, + "set": True, + } + + +async def get_tmux_preferences(connection) -> Dict: + """Get all tmux-related preferences in one call. + + Returns: + Dict with human-readable keys: + - open_tmux_windows_in: int (0=native_windows, 1=new_window, 2=tabs_in_existing) + - tmux_dashboard_limit: int + - auto_hide_tmux_client_session: bool + - use_tmux_profile: bool + """ + import iterm2 + + open_in = await iterm2.async_get_preference( + connection, iterm2.PreferenceKey.OPEN_TMUX_WINDOWS_IN + ) + dashboard_limit = await iterm2.async_get_preference( + connection, iterm2.PreferenceKey.TMUX_DASHBOARD_LIMIT + ) + auto_hide = await iterm2.async_get_preference( + connection, iterm2.PreferenceKey.AUTO_HIDE_TMUX_CLIENT_SESSION + ) + use_profile = await iterm2.async_get_preference( + connection, iterm2.PreferenceKey.USE_TMUX_PROFILE + ) + + return { + "open_tmux_windows_in": open_in, + "open_tmux_windows_in_label": {0: "native_windows", 1: "new_window", 2: "tabs_in_existing"}.get(open_in, "unknown"), + "tmux_dashboard_limit": dashboard_limit, + "auto_hide_tmux_client_session": auto_hide, + "use_tmux_profile": use_profile, + } + + +async def set_tmux_preference(connection, setting: str, value: Any) -> Dict: + """Set a tmux-specific preference by human-readable name. + + Args: + setting: One of: + - 'open_in': int 0=native_windows, 1=new_window, 2=tabs_in_existing + - 'dashboard_limit': int max entries shown in the tmux dashboard + - 'auto_hide_client': bool hide tmux client session automatically + - 'use_profile': bool use tmux profile for new windows + value: The value to set (parsed from string if needed). + + Returns: + Dict with 'setting', 'key', 'value', and 'set': True. + """ + import iterm2 + + setting_map = { + "open_in": iterm2.PreferenceKey.OPEN_TMUX_WINDOWS_IN, + "dashboard_limit": iterm2.PreferenceKey.TMUX_DASHBOARD_LIMIT, + "auto_hide_client": iterm2.PreferenceKey.AUTO_HIDE_TMUX_CLIENT_SESSION, + "use_profile": iterm2.PreferenceKey.USE_TMUX_PROFILE, + } + + if setting not in setting_map: + available = list(setting_map.keys()) + raise ValueError( + f"Unknown tmux setting '{setting}'. Available: {available}" + ) + + pref_key = setting_map[setting] + parsed = _parse_value(value) if isinstance(value, str) else value + + await iterm2.async_set_preference(connection, pref_key, parsed) + return { + "setting": setting, + "key": pref_key.name, + "value": parsed, + "set": True, + } + + +async def get_theme(connection) -> Dict: + """Get current iTerm2 theme information. + + Uses app.async_get_theme() which returns a list of theme tag strings + such as ["dark"], ["light"], ["dark", "highContrast"], etc. + + Returns: + Dict with 'tags' (list of strings) and 'is_dark' (bool). + """ + import iterm2 + app = await iterm2.async_get_app(connection) + tags = await app.async_get_theme() + return { + "tags": list(tags), + "is_dark": "dark" in tags, + } diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/core/profile.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/profile.py new file mode 100644 index 000000000..ed8e704f6 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/profile.py @@ -0,0 +1,80 @@ +"""Profile operations for iTerm2. + +Profiles define terminal appearance, behavior, keyboard mappings, etc. +All functions are async coroutines. +""" +from typing import Any, Dict, List, Optional + + +async def list_profiles(connection, name_filter: Optional[str] = None) -> List[Dict[str, Any]]: + """List all available profiles. + + Args: + name_filter: Optional substring filter on profile name. + + Returns: + List of dicts with profile name and GUID. + """ + import iterm2 + profiles = await iterm2.PartialProfile.async_query(connection) + result = [] + for p in profiles: + name = p.name or "(unnamed)" + if name_filter and name_filter.lower() not in name.lower(): + continue + result.append({ + "name": name, + "guid": p.guid, + }) + return result + + +async def get_profile_detail(connection, guid: str) -> Dict[str, Any]: + """Get detailed settings for a profile by GUID. + + Returns a subset of the most useful profile properties. + """ + import iterm2 + profiles = await iterm2.PartialProfile.async_query(connection) + for p in profiles: + if p.guid == guid: + full = await p.async_get_full_profile() + return { + "name": full.name, + "guid": full.guid, + "badge_text": full.badge_text, + } + raise ValueError(f"Profile with GUID '{guid}' not found.") + + +async def apply_color_preset( + connection, session_id: str, preset_name: str +) -> Dict[str, Any]: + """Apply a named color preset to a session's profile. + + Args: + session_id: Target session. + preset_name: Name of the color preset (e.g., 'Solarized Dark'). + + Returns: + Dict confirming the applied preset. + """ + import iterm2 + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + + preset = await iterm2.ColorPreset.async_get(connection, preset_name) + profile = await session.async_get_profile() + await profile.async_set_color_preset(preset) + await session.async_set_profile(profile) + return { + "session_id": session_id, + "preset_applied": preset_name, + } + + +async def list_color_presets(connection) -> List[str]: + """List all available color presets.""" + import iterm2 + presets = await iterm2.ColorPreset.async_get_list(connection) + return sorted(presets) diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/core/prompt.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/prompt.py new file mode 100644 index 000000000..39e8f0356 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/prompt.py @@ -0,0 +1,211 @@ +"""Shell prompt and command detection for iTerm2. + +Requires Shell Integration to be installed in the target session. +Install with: + curl -L https://iterm2.com/shell_integration/install_shell_integration.sh | bash + +All functions are async coroutines intended to be called via +cli_anything.iterm2_ctl.utils.iterm2_backend.run_iterm2(). +""" +import asyncio +from typing import Dict, List + + +def _prompt_to_dict(prompt) -> Dict: + """Convert an iterm2.Prompt object to a plain dict. + + Returns a dict with all available prompt fields. If the prompt is None + (Shell Integration not installed or no prompt yet), returns a dict with + 'available': False. + """ + if prompt is None: + return {"available": False} + + import iterm2 + + return { + "available": True, + "unique_id": prompt.unique_id, + "command": prompt.command, + "working_directory": prompt.working_directory, + "state": prompt.state.name if prompt.state is not None else None, + "has_prompt_range": prompt.prompt_range is not None, + "has_command_range": prompt.command_range is not None, + "has_output_range": prompt.output_range is not None, + } + + +async def get_last_prompt(connection, session_id: str) -> Dict: + """Get info about the last shell prompt in a session. + + Requires Shell Integration. Returns a dict with command, working_directory, + state (PromptState name), and range availability flags. If Shell Integration + is not installed or no prompt has been recorded yet, returns a dict with + 'available': False. + + Args: + session_id: The iTerm2 session ID to query. + + Returns: + Dict with prompt info, or {'available': False} if not available. + """ + import iterm2 + prompt = await iterm2.async_get_last_prompt(connection, session_id) + return _prompt_to_dict(prompt) + + +async def list_prompts(connection, session_id: str) -> Dict: + """List all recorded prompt IDs in a session. + + Requires Shell Integration. Each ID can be used to identify individual + command executions within the session's history. + + Args: + session_id: The iTerm2 session ID to query. + + Returns: + Dict with 'session_id' and 'prompt_ids' (list of strings). + """ + import iterm2 + prompt_ids = await iterm2.async_list_prompts(connection, session_id) + return { + "session_id": session_id, + "prompt_ids": list(prompt_ids) if prompt_ids else [], + "count": len(prompt_ids) if prompt_ids else 0, + } + + +async def wait_for_prompt( + connection, + session_id: str, + timeout: float = 30.0, +) -> Dict: + """Wait for the next shell prompt to appear in a session. + + Useful for waiting until a command finishes before sending the next one. + Monitors for a PROMPT event, which fires when the shell displays its + next prompt (i.e. the previous command has completed). + + Args: + session_id: The iTerm2 session ID to monitor. + timeout: Maximum seconds to wait. Default 30. + + Returns: + Dict with 'timed_out' (bool), and prompt info if available. + """ + import iterm2 + + result: Dict = {"session_id": session_id, "timed_out": False} + + async def _wait(conn): + async with iterm2.PromptMonitor( + conn, + session_id, + modes=[iterm2.PromptMonitor.Mode.PROMPT], + ) as mon: + mode, value = await mon.async_get() + result["mode"] = mode.name if mode is not None else None + result["value"] = value + + try: + await asyncio.wait_for(_wait(connection), timeout=timeout) + except asyncio.TimeoutError: + result["timed_out"] = True + + # Attempt to attach the latest prompt info after the event + if not result["timed_out"]: + import iterm2 as _iterm2 + prompt = await _iterm2.async_get_last_prompt(connection, session_id) + result.update(_prompt_to_dict(prompt)) + + return result + + +async def wait_for_command_end( + connection, + session_id: str, + timeout: float = 30.0, +) -> Dict: + """Wait for the current command to finish executing. + + Monitors for a COMMAND_END event. When a COMMAND_END fires, the + associated value is the integer exit status of the completed command. + + Args: + session_id: The iTerm2 session ID to monitor. + timeout: Maximum seconds to wait. Default 30. + + Returns: + Dict with 'exit_status' (int or None) and 'timed_out' (bool). + """ + import iterm2 + + result: Dict = { + "session_id": session_id, + "timed_out": False, + "exit_status": None, + } + + async def _wait(conn): + async with iterm2.PromptMonitor( + conn, + session_id, + modes=[iterm2.PromptMonitor.Mode.COMMAND_END], + ) as mon: + mode, value = await mon.async_get() + result["mode"] = mode.name if mode is not None else None + result["exit_status"] = value # int exit code for COMMAND_END + + try: + await asyncio.wait_for(_wait(connection), timeout=timeout) + except asyncio.TimeoutError: + result["timed_out"] = True + + return result + + +async def watch_prompt( + connection, + session_id: str, + count: int = 1, +) -> Dict: + """Watch for N prompt events and return them. + + Collects up to `count` events of any prompt type (PROMPT, COMMAND_START, + COMMAND_END) and returns them. Useful for monitoring a full command + lifecycle: COMMAND_START fires when the user hits Enter, COMMAND_END fires + when the command exits, and PROMPT fires when the shell re-displays its + prompt. + + Args: + session_id: The iTerm2 session ID to monitor. + count: Number of events to collect before returning. Default 1. + + Returns: + Dict with 'events': list of dicts, each containing 'mode' and 'value'. + """ + import iterm2 + + events: List[Dict] = [] + + async with iterm2.PromptMonitor( + connection, + session_id, + modes=[ + iterm2.PromptMonitor.Mode.PROMPT, + iterm2.PromptMonitor.Mode.COMMAND_START, + iterm2.PromptMonitor.Mode.COMMAND_END, + ], + ) as mon: + for _ in range(count): + mode, value = await mon.async_get() + events.append({ + "mode": mode.name if mode is not None else None, + "value": value, + }) + + return { + "session_id": session_id, + "events": events, + "event_count": len(events), + } diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/core/session.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/session.py new file mode 100644 index 000000000..0f85faf61 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/session.py @@ -0,0 +1,375 @@ +"""Session-level operations for iTerm2. + +A session is a single terminal pane. Tabs can contain multiple sessions +(split panes). All functions are async coroutines. +""" +from typing import Any, Dict, List, Optional + + +async def list_sessions( + connection, + window_id: Optional[str] = None, + tab_id: Optional[str] = None, +) -> List[Dict[str, Any]]: + """List all sessions, optionally filtered by window or tab.""" + import iterm2 + app = await iterm2.async_get_app(connection) + result = [] + + for window in app.windows: + if window_id and window.window_id != window_id: + continue + for tab in window.tabs: + if tab_id and tab.tab_id != tab_id: + continue + current_session = tab.current_session + for session in tab.sessions: + result.append({ + "session_id": session.session_id, + "name": session.name, + "tab_id": tab.tab_id, + "window_id": window.window_id, + "is_current": ( + current_session is not None + and session.session_id == current_session.session_id + ), + }) + return result + + +async def get_session_info(connection, session_id: str) -> Dict[str, Any]: + """Get info about a specific session.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + return { + "session_id": session.session_id, + "name": session.name, + } + + +async def send_text( + connection, + session_id: str, + text: str, + suppress_broadcast: bool = False, +) -> Dict[str, Any]: + """Send text/keystrokes to a session. + + Args: + session_id: Target session ID. + text: Text to send (use \\n for Enter). + suppress_broadcast: If True, suppress sending to broadcast domains. + + Returns: + Dict confirming the send. + """ + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + await session.async_send_text(text, suppress_broadcast=suppress_broadcast) + return { + "session_id": session_id, + "text_length": len(text), + "sent": True, + } + + +async def split_pane( + connection, + session_id: str, + vertical: bool = False, + before: bool = False, + profile: Optional[str] = None, + command: Optional[str] = None, +) -> Dict[str, Any]: + """Split a session into two panes. + + Args: + session_id: Session to split. + vertical: If True, split vertically (side by side). Default: horizontal (top/bottom). + before: If True, new pane appears before the split point. + profile: Profile name for new pane (None = same profile). + command: Command to run in new pane (None = shell). + + Returns: + Dict with new session_id. + """ + import iterm2 + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + + profile_customizations = None + if command: + customizations = iterm2.LocalWriteOnlyProfile() + customizations.set_use_custom_command("Yes") + customizations.set_command(command) + profile_customizations = customizations + + new_session = await session.async_split_pane( + vertical=vertical, + before=before, + profile=profile, + profile_customizations=profile_customizations, + ) + if new_session is None: + raise RuntimeError("Failed to split pane") + return { + "original_session_id": session_id, + "new_session_id": new_session.session_id, + "vertical": vertical, + } + + +async def close_session( + connection, session_id: str, force: bool = False +) -> Dict[str, Any]: + """Close a session.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + await session.async_close(force=force) + return {"session_id": session_id, "closed": True} + + +async def activate_session(connection, session_id: str) -> Dict[str, Any]: + """Bring a session to focus.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + await session.async_activate() + return {"session_id": session_id, "activated": True} + + +async def get_screen_contents( + connection, session_id: str, lines: Optional[int] = None +) -> Dict[str, Any]: + """Get the visible screen contents of a session. + + Args: + session_id: Target session. + lines: Number of lines to return (None = all visible lines). + + Returns: + Dict with 'lines' list and metadata. + """ + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + contents = await session.async_get_screen_contents() + total = contents.number_of_lines + limit = lines if lines is not None else total + screen_lines = [] + for i in range(min(limit, total)): + line = contents.line(i) + screen_lines.append(line.string) + return { + "session_id": session_id, + "total_lines": total, + "returned_lines": len(screen_lines), + "lines": screen_lines, + } + + +async def get_scrollback( + connection, + session_id: str, + lines: Optional[int] = None, + tail: Optional[int] = None, +) -> Dict[str, Any]: + """Get the full scrollback buffer including history beyond the visible screen. + + Uses async_get_line_info() + async_get_contents() inside a Transaction for + consistency. This reads ALL available lines โ€” scrollback history + visible + screen โ€” not just what's currently visible. + + Args: + session_id: Target session. + lines: Max total lines to return (None = all available). Applied from + the oldest line forward. + tail: If set, return only the last N lines (most recent). Takes + precedence over `lines`. + + Returns: + Dict with: + - lines: list of line strings (oldest โ†’ newest) + - total_available: scrollback_buffer_height + mutable_area_height + - scrollback_lines: lines in the history buffer + - screen_lines: lines in the visible mutable area + - overflow: lines lost due to buffer overflow + - returned_lines: count actually returned + """ + import iterm2 + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + + session = await async_find_session(connection, session_id) + + async with iterm2.Transaction(connection): + li = await session.async_get_line_info() + total_available = li.scrollback_buffer_height + li.mutable_area_height + + if tail is not None: + # Read only the last `tail` lines + want = min(tail, total_available) + first_line = li.overflow + (total_available - want) + count = want + elif lines is not None: + first_line = li.overflow + count = min(lines, total_available) + else: + first_line = li.overflow + count = total_available + + raw = await session.async_get_contents(first_line, count) + + result_lines = [lc.string for lc in raw] + return { + "session_id": session_id, + "total_available": total_available, + "scrollback_lines": li.scrollback_buffer_height, + "screen_lines": li.mutable_area_height, + "overflow": li.overflow, + "returned_lines": len(result_lines), + "lines": result_lines, + } + + +async def get_selection(connection, session_id: str) -> Dict[str, Any]: + """Get the currently selected text in a session.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + selection_text = await session.async_get_selection_text( + await session.async_get_selection() + ) + return { + "session_id": session_id, + "selected_text": selection_text, + "has_selection": bool(selection_text), + } + + +async def set_session_name(connection, session_id: str, name: str) -> Dict[str, Any]: + """Set the name of a session (shown in the tab bar).""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + await session.async_set_name(name) + return {"session_id": session_id, "name": name} + + +async def restart_session( + connection, session_id: str, only_if_exited: bool = False +) -> Dict[str, Any]: + """Restart a session.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + await session.async_restart(only_if_exited=only_if_exited) + return {"session_id": session_id, "restarted": True} + + +async def get_session_variable( + connection, session_id: str, variable_name: str +) -> Dict[str, Any]: + """Get a session variable value.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + value = await session.async_get_variable(variable_name) + return { + "session_id": session_id, + "variable": variable_name, + "value": value, + } + + +async def set_session_variable( + connection, session_id: str, variable_name: str, value: Any +) -> Dict[str, Any]: + """Set a session variable.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + await session.async_set_variable(variable_name, value) + return { + "session_id": session_id, + "variable": variable_name, + "value": value, + "set": True, + } + + +async def inject_bytes(connection, session_id: str, data: bytes) -> Dict[str, Any]: + """Inject raw bytes into a session's input stream (as if received from the shell).""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + await session.async_inject(data) + return {"session_id": session_id, "injected_bytes": len(data)} + + +def _get_process_name(pid) -> Optional[str]: + """Return the process name for a given PID using ps, or None on failure.""" + import subprocess + if pid is None: + return None + try: + result = subprocess.run( + ["ps", "-p", str(int(pid)), "-o", "comm="], + capture_output=True, text=True, timeout=2, + ) + name = result.stdout.strip() + return name.split("/")[-1] if name else None + except Exception: + return None + + +async def workspace_snapshot(connection) -> Dict[str, Any]: + """Rich snapshot of every session: name, path, pid, process, role, last screen line. + + For each session across all windows and tabs, returns: + - session_id, name, window_id, tab_id + - path: current working directory (from iTerm2 session variable) + - pid: shell PID (from iTerm2 session variable) + - process: foreground process name derived from pid via ps + - role: value of user.role session variable, or null if not set + - last_line: last non-empty line currently visible on screen + + Use this instead of app status when you need to understand *what is + happening* in each pane, not just that it exists. + """ + import iterm2 + app = await iterm2.async_get_app(connection) + sessions = [] + + for window in app.windows: + for tab in window.tabs: + for session in tab.sessions: + path = await session.async_get_variable("path") + pid = await session.async_get_variable("pid") + role = await session.async_get_variable("user.role") + process = _get_process_name(pid) + + last_line = None + contents = await session.async_get_screen_contents() + for i in range(contents.number_of_lines - 1, -1, -1): + line = contents.line(i).string.strip() + if line: + last_line = line + break + + sessions.append({ + "session_id": session.session_id, + "name": session.name, + "window_id": window.window_id, + "tab_id": tab.tab_id, + "path": path, + "pid": pid, + "process": process, + "role": role, + "last_line": last_line, + }) + + return {"session_count": len(sessions), "sessions": sessions} + + +async def set_grid_size( + connection, session_id: str, columns: int, rows: int +) -> Dict[str, Any]: + """Set the terminal grid size (columns x rows) for a session.""" + import iterm2 + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + size = iterm2.util.Size(width=columns, height=rows) + await session.async_set_grid_size(size) + return {"session_id": session_id, "columns": columns, "rows": rows} diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/core/session_state.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/session_state.py new file mode 100644 index 000000000..7a1528fea --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/session_state.py @@ -0,0 +1,107 @@ +"""Session state management for cli-anything-iterm2. + +Stores the current context (window, tab, session) in a JSON file so +commands remain stateful across CLI invocations. +""" +import fcntl +import json +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Optional + + +_DEFAULT_STATE_DIR = Path.home() / ".cli-anything-iterm2" +_DEFAULT_STATE_FILE = _DEFAULT_STATE_DIR / "session.json" + + +@dataclass +class SessionState: + """Tracks the current iTerm2 context.""" + window_id: Optional[str] = None + tab_id: Optional[str] = None + session_id: Optional[str] = None + # Metadata + notes: str = "" + + def to_dict(self) -> dict: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict) -> "SessionState": + return cls( + window_id=data.get("window_id"), + tab_id=data.get("tab_id"), + session_id=data.get("session_id"), + notes=data.get("notes", ""), + ) + + def clear(self): + self.window_id = None + self.tab_id = None + self.session_id = None + self.notes = "" + + def summary(self) -> str: + parts = [] + if self.window_id: + parts.append(f"window={self.window_id}") + if self.tab_id: + parts.append(f"tab={self.tab_id}") + if self.session_id: + parts.append(f"session={self.session_id}") + return ", ".join(parts) if parts else "no context set" + + +def default_state_path() -> Path: + return _DEFAULT_STATE_FILE + + +def load_state(path: Optional[str] = None) -> SessionState: + """Load session state from a JSON file. + + Returns an empty SessionState if the file does not exist. + """ + p = Path(path) if path else _DEFAULT_STATE_FILE + if not p.exists(): + return SessionState() + try: + with open(p, "r") as f: + data = json.load(f) + return SessionState.from_dict(data) + except (json.JSONDecodeError, OSError): + return SessionState() + + +def save_state(state: SessionState, path: Optional[str] = None) -> None: + """Save session state to a JSON file with exclusive file locking.""" + p = Path(path) if path else _DEFAULT_STATE_FILE + p.parent.mkdir(parents=True, exist_ok=True) + + data = state.to_dict() + + try: + f = open(str(p), "r+") + except FileNotFoundError: + f = open(str(p), "w") + + with f: + _locked = False + try: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + _locked = True + except (ImportError, OSError): + pass + try: + f.seek(0) + f.truncate() + json.dump(data, f, indent=2) + f.flush() + finally: + if _locked: + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + + +def clear_state(path: Optional[str] = None) -> None: + """Clear all session state.""" + save_state(SessionState(), path) diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/core/tab.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/tab.py new file mode 100644 index 000000000..523effd2c --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/tab.py @@ -0,0 +1,143 @@ +"""Tab-level operations for iTerm2. + +All functions are async coroutines intended to be called via +cli_anything.iterm2_ctl.utils.iterm2_backend.run_iterm2(). +""" +from typing import Any, Dict, List, Optional + + +async def list_tabs(connection, window_id: Optional[str] = None) -> List[Dict[str, Any]]: + """List all tabs, optionally filtered to a specific window.""" + import iterm2 + app = await iterm2.async_get_app(connection) + result = [] + for window in app.windows: + if window_id and window.window_id != window_id: + continue + current_tab = window.current_tab + for tab in window.tabs: + result.append({ + "tab_id": tab.tab_id, + "window_id": window.window_id, + "session_count": len(tab.sessions), + "is_current": (current_tab is not None and tab.tab_id == current_tab.tab_id), + }) + return result + + +async def create_tab( + connection, + window_id: Optional[str] = None, + profile: Optional[str] = None, + command: Optional[str] = None, + index: Optional[int] = None, +) -> Dict[str, Any]: + """Create a new tab in a window. + + Args: + window_id: Target window (None = current window). + profile: Profile name (None = default). + command: Command to run (None = shell). + index: Tab position (None = end). + + Returns: + Dict with tab_id, window_id, session_id. + """ + import iterm2 + app = await iterm2.async_get_app(connection) + + if window_id: + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_window + window = await async_find_window(connection, window_id) + else: + window = app.current_terminal_window + if window is None: + raise RuntimeError("No open windows. Create a window first with: window create") + + tab = await window.async_create_tab( + profile=profile, + command=command, + index=index, + ) + if tab is None: + raise RuntimeError("Failed to create tab") + + session = tab.current_session + return { + "tab_id": tab.tab_id, + "window_id": window.window_id, + "session_id": session.session_id if session else None, + } + + +async def close_tab(connection, tab_id: str, force: bool = False) -> Dict[str, Any]: + """Close a tab by ID.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_tab + tab = await async_find_tab(connection, tab_id) + # Close all sessions in the tab + for session in tab.sessions: + await session.async_close(force=force) + return {"tab_id": tab_id, "closed": True} + + +async def activate_tab(connection, tab_id: str) -> Dict[str, Any]: + """Bring a tab to focus.""" + import iterm2 + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_tab + tab = await async_find_tab(connection, tab_id) + session = tab.current_session + if session: + await session.async_activate() + return {"tab_id": tab_id, "activated": True} + + +async def select_pane_in_direction( + connection, + tab_id: str, + direction: str, +) -> Dict[str, Any]: + """Move focus to the adjacent split pane in a given direction. + + Args: + tab_id: The tab containing the split panes. + direction: One of 'left', 'right', 'above', 'below'. + + Returns: + Dict with 'new_session_id' (may be None if no pane in that direction). + """ + import iterm2 + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_tab + dir_map = { + "left": iterm2.NavigationDirection.LEFT, + "right": iterm2.NavigationDirection.RIGHT, + "above": iterm2.NavigationDirection.ABOVE, + "below": iterm2.NavigationDirection.BELOW, + } + nav_dir = dir_map.get(direction.lower()) + if nav_dir is None: + raise ValueError(f"Invalid direction '{direction}'. Use: left, right, above, below") + tab = await async_find_tab(connection, tab_id) + new_session_id = await tab.async_select_pane_in_direction(nav_dir) + return { + "tab_id": tab_id, + "direction": direction, + "new_session_id": new_session_id, + "moved": new_session_id is not None, + } + + +async def get_tab_info(connection, tab_id: str) -> Dict[str, Any]: + """Get detailed info about a specific tab.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_tab + tab = await async_find_tab(connection, tab_id) + sessions = [] + for s in tab.sessions: + sessions.append({ + "session_id": s.session_id, + "name": s.name, + }) + return { + "tab_id": tab.tab_id, + "session_count": len(sessions), + "sessions": sessions, + } diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/core/tmux.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/tmux.py new file mode 100644 index 000000000..57c6dfa11 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/tmux.py @@ -0,0 +1,244 @@ +"""Tmux integration operations for iTerm2. + +Exposes iTerm2's tmux -CC integration: list active connections, send tmux +commands, create tmux windows (shown as iTerm2 tabs), and show/hide them. + +All functions are async coroutines intended to be called via +cli_anything.iterm2_ctl.utils.iterm2_backend.run_iterm2(). + +Prerequisites: a tmux session must be attached via `tmux -CC` inside iTerm2 +for any connection to appear. The list commands work even with zero connections. +""" +from typing import Any, Dict, List, Optional + + +async def _ensure_app_and_connections(connection): + """Initialize App (sets up DELEGATE_FACTORY) then return tmux connections.""" + import iterm2 + # App must be instantiated first โ€” it registers the delegate factory that + # async_get_tmux_connections() requires. + await iterm2.async_get_app(connection) + return await iterm2.async_get_tmux_connections(connection) + + +async def _resolve_connection(connection, connection_id: Optional[str]): + """Return a TmuxConnection by ID, or the first one if ID is None.""" + connections = await _ensure_app_and_connections(connection) + if not connections: + raise RuntimeError( + "No active tmux connections. Start one with:\n" + " tmux -CC # in an iTerm2 terminal\n" + " tmux -CC attach # to attach to an existing session" + ) + if connection_id is None: + return connections[0] + for c in connections: + if c.connection_id == connection_id: + return c + available = [c.connection_id for c in connections] + raise ValueError( + f"Tmux connection '{connection_id}' not found. Available: {available}" + ) + + +async def list_connections(connection) -> List[Dict[str, Any]]: + """List all active iTerm2 tmux integration connections. + + Each connection corresponds to a running `tmux -CC` session. Returns + an empty list if no tmux integration is active. + """ + connections = await _ensure_app_and_connections(connection) + result = [] + for c in connections: + owning = c.owning_session + result.append({ + "connection_id": c.connection_id, + "owning_session_id": owning.session_id if owning else None, + "owning_session_name": owning.name if owning else None, + }) + return result + + +async def send_command( + connection, + command: str, + connection_id: Optional[str] = None, +) -> Dict[str, Any]: + """Send a tmux command to an active tmux integration connection. + + Args: + command: Any valid tmux command, e.g. "list-sessions", "new-window -n work". + connection_id: Which connection to use (None = first available). + + Returns: + Dict with the tmux command output. + """ + tc = await _resolve_connection(connection, connection_id) + output = await tc.async_send_command(command) + return { + "connection_id": tc.connection_id, + "command": command, + "output": output, + } + + +async def create_window( + connection, + connection_id: Optional[str] = None, +) -> Dict[str, Any]: + """Create a new tmux window via iTerm2's integration. + + The new tmux window surfaces as a new iTerm2 tab. Returns window and + tab info. + + Args: + connection_id: Which tmux connection to use (None = first available). + """ + tc = await _resolve_connection(connection, connection_id) + window = await tc.async_create_window() + if window is None: + raise RuntimeError("Failed to create tmux window โ€” got None from iTerm2") + tab = window.current_tab + session = tab.current_session if tab else None + return { + "connection_id": tc.connection_id, + "window_id": window.window_id, + "tab_id": tab.tab_id if tab else None, + "session_id": session.session_id if session else None, + } + + +async def set_window_visible( + connection, + tmux_window_id: str, + visible: bool, + connection_id: Optional[str] = None, +) -> Dict[str, Any]: + """Show or hide a tmux window (represented as an iTerm2 tab). + + Args: + tmux_window_id: The tmux window ID (from tab.tmux_window_id, e.g. "@1"). + visible: True to show, False to hide. + connection_id: Which tmux connection (None = first available). + """ + tc = await _resolve_connection(connection, connection_id) + await tc.async_set_tmux_window_visible(tmux_window_id, visible) + return { + "connection_id": tc.connection_id, + "tmux_window_id": tmux_window_id, + "visible": visible, + } + + +async def list_tmux_tabs(connection) -> List[Dict[str, Any]]: + """List all iTerm2 tabs that are backed by a tmux integration window. + + Returns only tabs that have a non-None tmux_window_id. + """ + import iterm2 + app = await iterm2.async_get_app(connection) + result = [] + for window in app.windows: + for tab in window.tabs: + if tab.tmux_window_id is not None: + result.append({ + "tab_id": tab.tab_id, + "window_id": window.window_id, + "tmux_window_id": tab.tmux_window_id, + "tmux_connection_id": tab.tmux_connection_id, + "session_count": len(tab.sessions), + }) + return result + + +async def bootstrap( + connection, + attach: bool = False, + session_id: Optional[str] = None, + timeout: float = 15.0, +) -> Dict[str, Any]: + """Start a tmux -CC session in iTerm2 and wait for the connection to appear. + + Sends `tmux -CC` (or `tmux -CC attach`) to a session, then polls + async_get_tmux_connections() until the connection materialises or the + timeout expires. + + Args: + attach: If True, send `tmux -CC attach` instead of `tmux -CC`. + session_id: Which iTerm2 session to start tmux in. If None, uses the + current window's first session. + timeout: Seconds to wait for the connection to appear. Default 15. + + Returns: + Dict with 'connection_id', 'owning_session_id', 'command', and + 'elapsed_seconds'. + """ + import asyncio + import iterm2 + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + + app = await iterm2.async_get_app(connection) + + # Resolve session + if session_id is not None: + target = await async_find_session(connection, session_id) + else: + # Fall back to first session in current window + if not app.windows: + raise RuntimeError("No iTerm2 windows open. Create one first.") + target = app.windows[0].current_tab.current_session + + # Snapshot existing connections so we detect the new one + existing_ids = { + c.connection_id + for c in await iterm2.async_get_tmux_connections(connection) + } + + cmd = "tmux -CC attach" if attach else "tmux -CC" + await target.async_send_text(cmd + "\n") + + # Poll until a new connection appears + start = asyncio.get_event_loop().time() + while True: + await asyncio.sleep(0.5) + current = await iterm2.async_get_tmux_connections(connection) + new_conns = [c for c in current if c.connection_id not in existing_ids] + if new_conns: + nc = new_conns[0] + owning = nc.owning_session + elapsed = asyncio.get_event_loop().time() - start + return { + "connection_id": nc.connection_id, + "owning_session_id": owning.session_id if owning else None, + "command": cmd, + "elapsed_seconds": round(elapsed, 2), + } + if asyncio.get_event_loop().time() - start > timeout: + raise RuntimeError( + f"Timed out after {timeout}s waiting for tmux -CC connection. " + "Make sure tmux is installed and no existing session conflicts." + ) + + +async def run_session_tmux_command( + connection, + session_id: str, + command: str, +) -> Dict[str, Any]: + """Run a tmux command from within a specific session. + + The session must be a tmux integration session (i.e. the shell running + inside it is connected to tmux -CC). Raises if the session is not tmux. + + Args: + session_id: The iTerm2 session ID. + command: A tmux command to run (e.g. "rename-window foo"). + """ + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_session + session = await async_find_session(connection, session_id) + output = await session.async_run_tmux_command(command) + return { + "session_id": session_id, + "command": command, + "output": output, + } diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/core/window.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/window.py new file mode 100644 index 000000000..ef35e8803 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/core/window.py @@ -0,0 +1,142 @@ +"""Window-level operations for iTerm2. + +All functions are async coroutines intended to be called via +cli_anything.iterm2_ctl.utils.iterm2_backend.run_iterm2(). +""" +from typing import Any, Dict, List, Optional + + +async def list_windows(connection) -> List[Dict[str, Any]]: + """Return a list of all open windows with metadata.""" + import iterm2 + app = await iterm2.async_get_app(connection) + result = [] + for window in app.windows: + tabs = window.tabs + tab_count = len(tabs) + session_count = sum(len(t.sessions) for t in tabs) + result.append({ + "window_id": window.window_id, + "tab_count": tab_count, + "session_count": session_count, + "is_current": (app.current_terminal_window is not None + and window.window_id == app.current_terminal_window.window_id), + }) + return result + + +async def create_window( + connection, + profile: Optional[str] = None, + command: Optional[str] = None, +) -> Dict[str, Any]: + """Create a new iTerm2 window. + + Args: + profile: Profile name to use (None = default). + command: Command to run in the new window (None = shell). + + Returns: + Dict with window_id, tab_id, session_id. + """ + import iterm2 + app = await iterm2.async_get_app(connection) + window = await iterm2.Window.async_create( + connection, profile=profile, command=command + ) + if window is None: + raise RuntimeError("Failed to create window") + tab = window.current_tab + session = tab.current_session if tab else None + return { + "window_id": window.window_id, + "tab_id": tab.tab_id if tab else None, + "session_id": session.session_id if session else None, + } + + +async def close_window(connection, window_id: str, force: bool = False) -> Dict[str, Any]: + """Close a window by ID.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_window + window = await async_find_window(connection, window_id) + await window.async_close(force=force) + return {"window_id": window_id, "closed": True} + + +async def activate_window(connection, window_id: str) -> Dict[str, Any]: + """Bring a window to focus.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_window + window = await async_find_window(connection, window_id) + await window.async_activate() + return {"window_id": window_id, "activated": True} + + +async def set_window_title(connection, window_id: str, title: str) -> Dict[str, Any]: + """Set the title of a window.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_window + window = await async_find_window(connection, window_id) + await window.async_set_title(title) + return {"window_id": window_id, "title": title} + + +async def get_window_frame(connection, window_id: str) -> Dict[str, Any]: + """Get the position and size of a window.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_window + window = await async_find_window(connection, window_id) + frame = await window.async_get_frame() + return { + "window_id": window_id, + "x": frame.origin.x, + "y": frame.origin.y, + "width": frame.size.width, + "height": frame.size.height, + } + + +async def set_window_frame( + connection, window_id: str, x: float, y: float, width: float, height: float +) -> Dict[str, Any]: + """Set the position and size of a window.""" + import iterm2 + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_window + window = await async_find_window(connection, window_id) + frame = iterm2.util.Frame( + origin=iterm2.util.Point(x=x, y=y), + size=iterm2.util.Size(width=width, height=height), + ) + await window.async_set_frame(frame) + return {"window_id": window_id, "x": x, "y": y, "width": width, "height": height} + + +async def get_window_fullscreen(connection, window_id: str) -> Dict[str, Any]: + """Check if a window is in fullscreen mode.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_window + window = await async_find_window(connection, window_id) + fullscreen = await window.async_get_fullscreen() + return {"window_id": window_id, "fullscreen": fullscreen} + + +async def set_window_fullscreen( + connection, window_id: str, fullscreen: bool +) -> Dict[str, Any]: + """Set fullscreen mode for a window.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import async_find_window + window = await async_find_window(connection, window_id) + await window.async_set_fullscreen(fullscreen) + return {"window_id": window_id, "fullscreen": fullscreen} + + +async def get_current_window(connection) -> Optional[Dict[str, Any]]: + """Return info about the currently focused window.""" + import iterm2 + app = await iterm2.async_get_app(connection) + window = app.current_terminal_window + if window is None: + return None + tab = window.current_tab + session = tab.current_session if tab else None + return { + "window_id": window.window_id, + "tab_id": tab.tab_id if tab else None, + "session_id": session.session_id if session else None, + } diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/iterm2_ctl_cli.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/iterm2_ctl_cli.py new file mode 100644 index 000000000..b281bab3c --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/iterm2_ctl_cli.py @@ -0,0 +1,1535 @@ +#!/usr/bin/env python3 +"""cli-anything-iterm2 โ€” Stateful CLI harness for iTerm2. + +Controls a running iTerm2 instance programmatically via the iTerm2 Python API. +Supports one-shot commands and an interactive REPL. + +Usage: + # One-shot commands + cli-anything-iterm2 app status + cli-anything-iterm2 window list + cli-anything-iterm2 window create --profile "Default" + cli-anything-iterm2 session send --session-id "echo hello\\n" + + # Interactive REPL (default when invoked with no subcommand) + cli-anything-iterm2 +""" + +import json +import os +import sys +from typing import Optional + +import click + +from cli_anything.iterm2_ctl.core import ( + arrangement as arr_mod, + broadcast as bcast_mod, + dialogs as dlg_mod, + menu as menu_mod, + pref as pref_mod, + profile as profile_mod, + prompt as prompt_mod, + session as sess_mod, + session_state, + tab as tab_mod, + tmux as tmux_mod, + window as win_mod, +) +from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + +# โ”€โ”€ Global state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +_json_output = False +_state: Optional[session_state.SessionState] = None + + +def get_state() -> session_state.SessionState: + global _state + if _state is None: + _state = session_state.load_state() + return _state + + +def save_state_now(): + global _state + if _state is not None: + session_state.save_state(_state) + + +def output(data, message: str = ""): + """Print result as JSON (--json) or human-readable.""" + if _json_output: + print(json.dumps(data, indent=2, default=str)) + else: + if message: + click.echo(message) + if data and not message: + _print_data(data) + + +def _print_data(data, indent: int = 0): + prefix = " " * indent + if isinstance(data, dict): + for k, v in data.items(): + if isinstance(v, (dict, list)): + click.echo(f"{prefix}{k}:") + _print_data(v, indent + 1) + else: + click.echo(f"{prefix}{k}: {v}") + elif isinstance(data, list): + for item in data: + if isinstance(item, dict): + _print_data(item, indent) + click.echo(f"{prefix}---") + else: + click.echo(f"{prefix}- {item}") + else: + click.echo(f"{prefix}{data}") + + +def handle_iterm2_error(func): + """Decorator to format iTerm2 errors nicely.""" + import functools + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except RuntimeError as e: + if _json_output: + print(json.dumps({"error": str(e)}, indent=2)) + else: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + except ValueError as e: + if _json_output: + print(json.dumps({"error": str(e)}, indent=2)) + else: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + return wrapper + + +# โ”€โ”€ Root CLI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@click.group(invoke_without_command=True) +@click.option("--json", "use_json", is_flag=True, default=False, + help="Output results as JSON (for agent use).") +@click.pass_context +def cli(ctx, use_json): + """cli-anything-iterm2 โ€” Control iTerm2 from the command line. + + Connects to a running iTerm2 instance via the iTerm2 Python API. + Run without a subcommand to enter the interactive REPL. + + Prerequisites: + 1. iTerm2 must be running + 2. Python API enabled: Preferences โ†’ General โ†’ Magic โ†’ Enable Python API + """ + global _json_output + _json_output = use_json + ctx.ensure_object(dict) + ctx.obj["json"] = use_json + + if ctx.invoked_subcommand is None: + ctx.invoke(repl) + + +def main(): + cli() + + +# โ”€โ”€ App group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@cli.group() +def app(): + """Application-level information and status.""" + + +@app.command("status") +@handle_iterm2_error +def app_status(): + """Show current iTerm2 app status (windows, tabs, sessions).""" + def _get_status(connection): + import asyncio + + async def _inner(conn): + import iterm2 + a = await iterm2.async_get_app(conn) + windows = [] + for w in a.windows: + tabs = [] + for t in w.tabs: + sessions = [{"session_id": s.session_id, "name": s.name} + for s in t.sessions] + tabs.append({"tab_id": t.tab_id, "sessions": sessions}) + windows.append({"window_id": w.window_id, "tabs": tabs}) + return { + "window_count": len(a.windows), + "windows": windows, + } + return _inner(connection) + + result = run_iterm2(_get_status) + output(result, f"iTerm2: {result['window_count']} window(s)") + + +@app.command("current") +@handle_iterm2_error +def app_current(): + """Show the currently focused window/tab/session.""" + result = run_iterm2(win_mod.get_current_window) + if result is None: + output({"current": None}, "No window is currently focused.") + else: + state = get_state() + state.window_id = result.get("window_id") + state.tab_id = result.get("tab_id") + state.session_id = result.get("session_id") + save_state_now() + output(result, f"Current: window={result.get('window_id')} " + f"tab={result.get('tab_id')} session={result.get('session_id')}") + + +@app.command("context") +def app_context(): + """Show the saved session context (current window/tab/session IDs).""" + state = get_state() + data = state.to_dict() + output(data, f"Context: {state.summary()}") + + +@app.command("set-context") +@click.option("--window-id", default=None, help="Window ID to set as current.") +@click.option("--tab-id", default=None, help="Tab ID to set as current.") +@click.option("--session-id", default=None, help="Session ID to set as current.") +def app_set_context(window_id, tab_id, session_id): + """Manually set the session context (window/tab/session IDs).""" + state = get_state() + if window_id: + state.window_id = window_id + if tab_id: + state.tab_id = tab_id + if session_id: + state.session_id = session_id + save_state_now() + output(state.to_dict(), f"Context updated: {state.summary()}") + + +@app.command("clear-context") +def app_clear_context(): + """Clear the saved session context.""" + state = get_state() + state.clear() + save_state_now() + output({}, "Context cleared.") + + +@app.command("get-var") +@click.argument("variable_name") +@handle_iterm2_error +def app_get_var(variable_name): + """Get an app-level iTerm2 variable.""" + def _get(conn): + async def _inner(c): + import iterm2 + a = await iterm2.async_get_app(c) + value = await a.async_get_variable(variable_name) + return {"variable": variable_name, "value": value} + return _inner(conn) + result = run_iterm2(_get) + output(result, f"{variable_name} = {result['value']}") + + +@app.command("set-var") +@click.argument("variable_name") +@click.argument("value") +@handle_iterm2_error +def app_set_var(variable_name, value): + """Set an app-level iTerm2 variable (user.* namespace).""" + def _set(conn): + async def _inner(c): + import iterm2 + a = await iterm2.async_get_app(c) + await a.async_set_variable(variable_name, value) + return {"variable": variable_name, "value": value, "set": True} + return _inner(conn) + result = run_iterm2(_set) + output(result, f"Set {variable_name} = {value}") + + +@app.command("alert") +@click.argument("title") +@click.argument("subtitle") +@click.option("--button", "buttons", multiple=True, + help="Add a button label. Repeat for multiple buttons.") +@click.option("--window-id", default=None, help="Attach to a specific window.") +@handle_iterm2_error +def app_alert(title, subtitle, buttons, window_id): + """Show a modal alert dialog with optional custom buttons. + + Returns the label of the button the user clicked. + + \b + cli-anything-iterm2 app alert "Deploy?" "Push to production?" + cli-anything-iterm2 app alert "Choose" "Pick one" --button Yes --button No + """ + wid = window_id or get_state().window_id + result = run_iterm2(dlg_mod.show_alert, title, subtitle, + buttons=list(buttons) or None, window_id=wid) + output(result, f"Clicked: {result['button_label']}") + + +@app.command("text-input") +@click.argument("title") +@click.argument("subtitle") +@click.option("--placeholder", default="", help="Gray placeholder text.") +@click.option("--default", "default_value", default="", help="Pre-filled text.") +@click.option("--window-id", default=None) +@handle_iterm2_error +def app_text_input(title, subtitle, placeholder, default_value, window_id): + """Show a modal alert with a text input field. + + Returns the text the user typed, or indicates cancellation. + + \b + cli-anything-iterm2 app text-input "Rename" "Enter new name:" --default "myapp" + """ + wid = window_id or get_state().window_id + result = run_iterm2(dlg_mod.show_text_input, title, subtitle, + placeholder=placeholder, default_value=default_value, + window_id=wid) + if result["cancelled"]: + output(result, "Cancelled.") + else: + output(result, f"Input: {result['text']}") + + +@app.command("file-panel") +@click.option("--title", default="Open", help="Panel message text.") +@click.option("--path", default=None, help="Initial directory.") +@click.option("--ext", "extensions", multiple=True, + help="Allowed extensions, e.g. --ext py --ext txt") +@click.option("--dirs", is_flag=True, default=False, + help="Allow choosing directories.") +@click.option("--multi", is_flag=True, default=False, + help="Allow multiple file selection.") +@handle_iterm2_error +def app_file_panel(title, path, extensions, dirs, multi): + """Show a macOS Open File panel and return the chosen path(s). + + \b + cli-anything-iterm2 app file-panel + cli-anything-iterm2 app file-panel --path ~/Documents --ext py --ext txt + cli-anything-iterm2 app file-panel --dirs --multi + """ + result = run_iterm2(dlg_mod.show_open_panel, title, path=path, + extensions=list(extensions) or None, + can_choose_directories=dirs, + allows_multiple=multi) + if result["cancelled"]: + output(result, "Cancelled.") + else: + output(result, f"Selected: {', '.join(result['files'])}") + + +@app.command("save-panel") +@click.option("--title", default="Save", help="Panel message text.") +@click.option("--path", default=None, help="Initial directory.") +@click.option("--filename", default=None, help="Pre-filled filename.") +@handle_iterm2_error +def app_save_panel(title, path, filename): + """Show a macOS Save File panel and return the chosen path. + + \b + cli-anything-iterm2 app save-panel + cli-anything-iterm2 app save-panel --path ~/Desktop --filename output.txt + """ + result = run_iterm2(dlg_mod.show_save_panel, title, path=path, filename=filename) + if result["cancelled"]: + output(result, "Cancelled.") + else: + output(result, f"Save to: {result['file']}") + + +@app.command("snapshot") +@handle_iterm2_error +def app_snapshot(): + """Rich workspace snapshot: every session with path, process, role, and last output line. + + \b + Use this to orient in an existing workspace without reading full screen contents + for every pane. Returns name, current directory, foreground process, user.role + label, and the last non-empty visible line for each session. + + cli-anything-iterm2 --json app snapshot + """ + result = run_iterm2(sess_mod.workspace_snapshot) + output(result, f"Workspace: {result['session_count']} session(s)") + if not _json_output: + for s in result["sessions"]: + role_tag = f" [{s['role']}]" if s.get("role") else "" + process_tag = f" ({s['process']})" if s.get("process") else "" + path_tag = f" {s['path']}" if s.get("path") else "" + click.echo(f" {s['session_id']} {s['name']}{role_tag}{process_tag}{path_tag}") + if s.get("last_line"): + click.echo(f" > {s['last_line']}") + + +# โ”€โ”€ Window group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@cli.group() +def window(): + """Manage iTerm2 windows.""" + + +@window.command("list") +@handle_iterm2_error +def window_list(): + """List all open windows.""" + result = run_iterm2(win_mod.list_windows) + output({"windows": result}, + f"{len(result)} window(s)" if result else "No windows open.") + if not _json_output and result: + for w in result: + current_mark = " *" if w.get("is_current") else "" + click.echo(f" {w['window_id']}{current_mark} " + f"tabs={w['tab_count']} sessions={w['session_count']}") + + +@window.command("create") +@click.option("--profile", "-p", default=None, help="Profile name.") +@click.option("--command", "-c", default=None, help="Command to run.") +@click.option("--use-as-context", is_flag=True, default=False, + help="Save new window/tab/session as the current context.") +@handle_iterm2_error +def window_create(profile, command, use_as_context): + """Create a new iTerm2 window.""" + result = run_iterm2(win_mod.create_window, profile=profile, command=command) + if use_as_context: + state = get_state() + state.window_id = result.get("window_id") + state.tab_id = result.get("tab_id") + state.session_id = result.get("session_id") + save_state_now() + output(result, f"Created window {result['window_id']}") + + +@window.command("close") +@click.argument("window_id", required=False) +@click.option("--force", is_flag=True, default=False, help="Force close without confirmation.") +@handle_iterm2_error +def window_close(window_id, force): + """Close a window. Uses context window if WINDOW_ID is omitted.""" + wid = window_id or get_state().window_id + if not wid: + raise click.UsageError("No window ID specified and no context window set. " + "Use 'app current' or 'app set-context' first.") + result = run_iterm2(win_mod.close_window, wid, force=force) + output(result, f"Closed window {wid}") + + +@window.command("activate") +@click.argument("window_id", required=False) +@handle_iterm2_error +def window_activate(window_id): + """Bring a window to the foreground.""" + wid = window_id or get_state().window_id + if not wid: + raise click.UsageError("No window ID specified.") + result = run_iterm2(win_mod.activate_window, wid) + output(result, f"Activated window {wid}") + + +@window.command("set-title") +@click.argument("title") +@click.option("--window-id", default=None) +@handle_iterm2_error +def window_set_title(title, window_id): + """Set the title of a window.""" + wid = window_id or get_state().window_id + if not wid: + raise click.UsageError("No window ID specified.") + result = run_iterm2(win_mod.set_window_title, wid, title) + output(result, f"Set title of {wid} to '{title}'") + + +@window.command("frame") +@click.option("--window-id", default=None) +@handle_iterm2_error +def window_frame(window_id): + """Get the position and size of a window.""" + wid = window_id or get_state().window_id + if not wid: + raise click.UsageError("No window ID specified.") + result = run_iterm2(win_mod.get_window_frame, wid) + output(result, f"Window {wid}: x={result['x']} y={result['y']} " + f"w={result['width']} h={result['height']}") + + +@window.command("set-frame") +@click.option("--window-id", default=None) +@click.option("--x", type=float, required=True) +@click.option("--y", type=float, required=True) +@click.option("--width", type=float, required=True) +@click.option("--height", type=float, required=True) +@handle_iterm2_error +def window_set_frame(window_id, x, y, width, height): + """Set the position and size of a window.""" + wid = window_id or get_state().window_id + if not wid: + raise click.UsageError("No window ID specified.") + result = run_iterm2(win_mod.set_window_frame, wid, x, y, width, height) + output(result, f"Moved window {wid} to ({x},{y}) size {width}x{height}") + + +@window.command("fullscreen") +@click.argument("mode", type=click.Choice(["on", "off", "toggle", "status"])) +@click.option("--window-id", default=None) +@handle_iterm2_error +def window_fullscreen(mode, window_id): + """Control fullscreen mode for a window.""" + wid = window_id or get_state().window_id + if not wid: + raise click.UsageError("No window ID specified.") + if mode == "status": + result = run_iterm2(win_mod.get_window_fullscreen, wid) + output(result, f"Window {wid} fullscreen: {result['fullscreen']}") + else: + if mode == "toggle": + status = run_iterm2(win_mod.get_window_fullscreen, wid) + target = not status["fullscreen"] + else: + target = mode == "on" + result = run_iterm2(win_mod.set_window_fullscreen, wid, target) + output(result, f"Window {wid} fullscreen: {target}") + + +# โ”€โ”€ Tab group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@cli.group() +def tab(): + """Manage tabs within iTerm2 windows.""" + + +@tab.command("list") +@click.option("--window-id", default=None, help="Filter to specific window.") +@handle_iterm2_error +def tab_list(window_id): + """List all tabs.""" + wid = window_id or get_state().window_id + result = run_iterm2(tab_mod.list_tabs, window_id=wid) + output({"tabs": result}, f"{len(result)} tab(s)") + if not _json_output and result: + for t in result: + current_mark = " *" if t.get("is_current") else "" + click.echo(f" {t['tab_id']}{current_mark} " + f"window={t['window_id']} sessions={t['session_count']}") + + +@tab.command("create") +@click.option("--window-id", default=None) +@click.option("--profile", "-p", default=None) +@click.option("--command", "-c", default=None) +@click.option("--use-as-context", is_flag=True, default=False) +@handle_iterm2_error +def tab_create(window_id, profile, command, use_as_context): + """Create a new tab.""" + wid = window_id or get_state().window_id + result = run_iterm2(tab_mod.create_tab, window_id=wid, profile=profile, command=command) + if use_as_context: + state = get_state() + state.window_id = result.get("window_id") + state.tab_id = result.get("tab_id") + state.session_id = result.get("session_id") + save_state_now() + output(result, f"Created tab {result['tab_id']} in window {result['window_id']}") + + +@tab.command("close") +@click.argument("tab_id", required=False) +@click.option("--force", is_flag=True, default=False) +@handle_iterm2_error +def tab_close(tab_id, force): + """Close a tab.""" + tid = tab_id or get_state().tab_id + if not tid: + raise click.UsageError("No tab ID specified.") + result = run_iterm2(tab_mod.close_tab, tid, force=force) + output(result, f"Closed tab {tid}") + + +@tab.command("activate") +@click.argument("tab_id", required=False) +@handle_iterm2_error +def tab_activate(tab_id): + """Focus a tab.""" + tid = tab_id or get_state().tab_id + if not tid: + raise click.UsageError("No tab ID specified.") + result = run_iterm2(tab_mod.activate_tab, tid) + output(result, f"Activated tab {tid}") + + +@tab.command("info") +@click.argument("tab_id", required=False) +@handle_iterm2_error +def tab_info(tab_id): + """Get details about a tab.""" + tid = tab_id or get_state().tab_id + if not tid: + raise click.UsageError("No tab ID specified.") + result = run_iterm2(tab_mod.get_tab_info, tid) + output(result) + + +@tab.command("select-pane") +@click.argument("direction", type=click.Choice(["left", "right", "above", "below"])) +@click.option("--tab-id", default=None) +@handle_iterm2_error +def tab_select_pane(direction, tab_id): + """Move focus to the adjacent split pane in a direction. + + DIRECTION: left | right | above | below + + \b + cli-anything-iterm2 tab select-pane right + cli-anything-iterm2 tab select-pane below --tab-id + """ + tid = tab_id or get_state().tab_id + if not tid: + raise click.UsageError("No tab ID specified.") + result = run_iterm2(tab_mod.select_pane_in_direction, tid, direction) + if result["moved"]: + output(result, f"Moved focus {direction} โ†’ session {result['new_session_id']}") + else: + output(result, f"No pane {direction} of current selection.") + + +# โ”€โ”€ Session group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@cli.group() +def session(): + """Manage terminal sessions (panes) within iTerm2 tabs.""" + + +@session.command("list") +@click.option("--window-id", default=None) +@click.option("--tab-id", default=None) +@handle_iterm2_error +def session_list(window_id, tab_id): + """List all sessions.""" + wid = window_id or get_state().window_id + tid = tab_id or get_state().tab_id + result = run_iterm2(sess_mod.list_sessions, window_id=wid, tab_id=tid) + output({"sessions": result}, f"{len(result)} session(s)") + if not _json_output and result: + for s in result: + current_mark = " *" if s.get("is_current") else "" + click.echo(f" {s['session_id']}{current_mark} " + f"name={s['name'] or '(unnamed)'} " + f"tab={s['tab_id']}") + + +@session.command("send") +@click.argument("text") +@click.option("--session-id", default=None) +@click.option("--no-newline", is_flag=True, default=False, + help="Do not append a newline.") +@click.option("--suppress-broadcast", is_flag=True, default=False, + help="Suppress sending to broadcast domains.") +@handle_iterm2_error +def session_send(text, session_id, no_newline, suppress_broadcast): + """Send text to a session. + + TEXT: The text to send. Use \\n for newlines. A newline is appended + unless --no-newline is given. + + Example: + cli-anything-iterm2 session send "ls -la" + cli-anything-iterm2 session send "pwd" --session-id w0t0p0 + """ + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified. Use --session-id or set context " + "with 'app current' or 'app set-context'.") + payload = text if no_newline else (text + "\n") + result = run_iterm2(sess_mod.send_text, sid, payload, suppress_broadcast=suppress_broadcast) + output(result, f"Sent {result['text_length']} chars to session {sid}") + + +@session.command("screen") +@click.option("--session-id", default=None) +@click.option("--lines", "-n", type=int, default=None, help="Max lines to return.") +@handle_iterm2_error +def session_screen(session_id, lines): + """Get the visible screen contents of a session.""" + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(sess_mod.get_screen_contents, sid, lines=lines) + output(result) + if not _json_output: + click.echo(f" Session {sid} ({result['returned_lines']}/{result['total_lines']} lines)") + click.echo(" " + "โ”€" * 60) + for line in result["lines"]: + click.echo(f" {line}") + + +@session.command("scrollback") +@click.option("--session-id", default=None) +@click.option("--lines", "-n", type=int, default=None, + help="Max lines to return (default: all).") +@click.option("--tail", "-t", type=int, default=None, + help="Return only the last N lines (most recent). Overrides --lines.") +@click.option("--strip", is_flag=True, default=False, + help="Strip null bytes and non-printable control characters.") +@handle_iterm2_error +def session_scrollback(session_id, lines, tail, strip): + """Get the full scrollback buffer including history beyond the visible screen. + + Unlike 'screen' which only shows the visible terminal area, this reads + the entire history buffer โ€” everything since the session started (up to + the scrollback limit). + + \b + cli-anything-iterm2 session scrollback # all history + cli-anything-iterm2 session scrollback --tail 100 # last 100 lines + cli-anything-iterm2 session scrollback --lines 500 # first 500 lines + """ + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(sess_mod.get_scrollback, sid, lines=lines, tail=tail) + if strip: + import re + result["lines"] = [ + re.sub(r"[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]", "", ln) + for ln in result["lines"] + ] + output(result) + if not _json_output: + click.echo(f" Session {sid} ({result['returned_lines']} lines, " + f"scrollback={result['scrollback_lines']} screen={result['screen_lines']} " + f"overflow={result['overflow']})") + click.echo(" " + "โ”€" * 60) + for line in result["lines"]: + click.echo(f" {line}") + + +@session.command("split") +@click.option("--session-id", default=None) +@click.option("--vertical", "-v", is_flag=True, default=False, + help="Split vertically (side by side). Default: horizontal.") +@click.option("--before", is_flag=True, default=False, + help="Insert new pane before the split point.") +@click.option("--profile", "-p", default=None) +@click.option("--command", "-c", default=None) +@click.option("--use-as-context", is_flag=True, default=False) +@handle_iterm2_error +def session_split(session_id, vertical, before, profile, command, use_as_context): + """Split a session into two panes.""" + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(sess_mod.split_pane, sid, + vertical=vertical, before=before, + profile=profile, command=command) + if use_as_context: + state = get_state() + state.session_id = result.get("new_session_id") + save_state_now() + direction = "vertically" if vertical else "horizontally" + output(result, f"Split {direction}: new session {result['new_session_id']}") + + +@session.command("close") +@click.argument("session_id", required=False) +@click.option("--force", is_flag=True, default=False) +@handle_iterm2_error +def session_close(session_id, force): + """Close a session.""" + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(sess_mod.close_session, sid, force=force) + output(result, f"Closed session {sid}") + + +@session.command("activate") +@click.argument("session_id", required=False) +@handle_iterm2_error +def session_activate(session_id): + """Focus a session.""" + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(sess_mod.activate_session, sid) + output(result, f"Activated session {sid}") + + +@session.command("set-name") +@click.argument("name") +@click.option("--session-id", default=None) +@handle_iterm2_error +def session_set_name(name, session_id): + """Set the display name of a session.""" + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(sess_mod.set_session_name, sid, name) + output(result, f"Named session {sid} '{name}'") + + +@session.command("restart") +@click.option("--session-id", default=None) +@click.option("--only-if-exited", is_flag=True, default=False) +@handle_iterm2_error +def session_restart(session_id, only_if_exited): + """Restart a session.""" + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(sess_mod.restart_session, sid, only_if_exited=only_if_exited) + output(result, f"Restarted session {sid}") + + +@session.command("get-var") +@click.argument("variable_name") +@click.option("--session-id", default=None) +@handle_iterm2_error +def session_get_var(variable_name, session_id): + """Get a session variable value.""" + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(sess_mod.get_session_variable, sid, variable_name) + output(result, f"{variable_name} = {result['value']}") + + +@session.command("set-var") +@click.argument("variable_name") +@click.argument("value") +@click.option("--session-id", default=None) +@handle_iterm2_error +def session_set_var(variable_name, value, session_id): + """Set a session variable.""" + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(sess_mod.set_session_variable, sid, variable_name, value) + output(result, f"Set {variable_name} = {value}") + + +@session.command("resize") +@click.option("--session-id", default=None) +@click.option("--columns", "-c", type=int, required=True) +@click.option("--rows", "-r", type=int, required=True) +@handle_iterm2_error +def session_resize(session_id, columns, rows): + """Resize a session terminal grid.""" + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(sess_mod.set_grid_size, sid, columns, rows) + output(result, f"Resized session {sid} to {columns}x{rows}") + + +@session.command("run-tmux-cmd") +@click.argument("command") +@click.option("--session-id", default=None) +@handle_iterm2_error +def session_run_tmux_cmd(command, session_id): + """Run a tmux command from within a tmux-integrated session. + + The session must be one where `tmux -CC` was started (the "gateway" + session). Raises if the session is not a tmux integration session. + + Example: + cli-anything-iterm2 session run-tmux-cmd "rename-window mywork" + cli-anything-iterm2 session run-tmux-cmd "list-sessions" + """ + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(tmux_mod.run_session_tmux_command, sid, command) + output(result, f"tmux [{sid}]: {result.get('output', '').strip()}") + + +@session.command("selection") +@click.option("--session-id", default=None) +@handle_iterm2_error +def session_selection(session_id): + """Get the selected text in a session.""" + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(sess_mod.get_selection, sid) + output(result) + if not _json_output: + if result["has_selection"]: + click.echo(result["selected_text"]) + else: + click.echo("(no selection)") + + +@session.command("inject") +@click.argument("data") +@click.option("--session-id", default=None) +@click.option("--hex", "use_hex", is_flag=True, default=False, + help="Interpret DATA as a hex string (e.g. '1b5b41' for ESC[A).") +@handle_iterm2_error +def session_inject(data, session_id, use_hex): + """Inject raw bytes into a session as if received from the shell. + + Useful for sending escape sequences, OSC codes, or other terminal control + bytes that would normally come from a running program. + + \b + cli-anything-iterm2 session inject $'\\x1b[2J' # clear screen (ESC[2J) + cli-anything-iterm2 session inject "1b5b324a" --hex # same in hex + cli-anything-iterm2 session inject $'\\x07' # bell + """ + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + if use_hex: + try: + raw = bytes.fromhex(data) + except ValueError as e: + raise click.UsageError(f"Invalid hex string: {e}") + else: + raw = data.encode("utf-8", errors="surrogateescape") + result = run_iterm2(sess_mod.inject_bytes, sid, raw) + output(result, f"Injected {result['injected_bytes']} byte(s) into session {sid}") + + +@session.command("get-prompt") +@click.option("--session-id", default=None) +@handle_iterm2_error +def session_get_prompt(session_id): + """Get the last shell prompt info (requires Shell Integration).""" + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(prompt_mod.get_last_prompt, sid) + output(result) + if not _json_output: + if result.get("available"): + click.echo(f" command: {result.get('command')}") + click.echo(f" cwd: {result.get('working_directory')}") + click.echo(f" state: {result.get('state')}") + else: + click.echo(" Shell Integration not available in this session.") + + +@session.command("wait-prompt") +@click.option("--session-id", default=None) +@click.option("--timeout", "-t", type=float, default=30.0, + help="Seconds to wait (default 30).") +@handle_iterm2_error +def session_wait_prompt(session_id, timeout): + """Wait for the next shell prompt (requires Shell Integration). + + Blocks until the shell in the session displays its next prompt, meaning + the previously running command has completed. + """ + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(prompt_mod.wait_for_prompt, sid, timeout=timeout) + if result.get("timed_out"): + output(result, f"Timed out after {timeout}s waiting for prompt.") + else: + output(result, f"Prompt received in session {sid}") + + +@session.command("wait-command-end") +@click.option("--session-id", default=None) +@click.option("--timeout", "-t", type=float, default=30.0, + help="Seconds to wait (default 30).") +@handle_iterm2_error +def session_wait_command_end(session_id, timeout): + """Wait for the current command to finish (requires Shell Integration). + + Returns the exit status of the completed command. + """ + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(prompt_mod.wait_for_command_end, sid, timeout=timeout) + if result.get("timed_out"): + output(result, f"Timed out after {timeout}s.") + else: + output(result, f"Command ended, exit_status={result.get('exit_status')}") + + +# โ”€โ”€ Profile group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@cli.group() +def profile(): + """Manage iTerm2 profiles.""" + + +@profile.command("list") +@click.option("--filter", "name_filter", default=None, + help="Filter by name substring.") +@handle_iterm2_error +def profile_list(name_filter): + """List all available profiles.""" + result = run_iterm2(profile_mod.list_profiles, name_filter=name_filter) + output({"profiles": result}, f"{len(result)} profile(s)") + if not _json_output and result: + for p in result: + click.echo(f" {p['name']} ({p['guid']})") + + +@profile.command("get") +@click.argument("guid") +@handle_iterm2_error +def profile_get(guid): + """Get detailed settings for a profile by GUID. + + GUID: The profile GUID from `profile list`. + + \b + cli-anything-iterm2 profile list # find the GUID + cli-anything-iterm2 profile get # get details + """ + result = run_iterm2(profile_mod.get_profile_detail, guid) + output(result) + if not _json_output: + click.echo(f" name: {result['name']}") + click.echo(f" guid: {result['guid']}") + click.echo(f" badge_text: {result.get('badge_text') or '(none)'}") + + +@profile.command("color-presets") +@handle_iterm2_error +def profile_color_presets(): + """List all available color presets.""" + result = run_iterm2(profile_mod.list_color_presets) + output({"color_presets": result}, f"{len(result)} color preset(s)") + if not _json_output and result: + for p in result: + click.echo(f" {p}") + + +@profile.command("apply-preset") +@click.argument("preset_name") +@click.option("--session-id", default=None) +@handle_iterm2_error +def profile_apply_preset(preset_name, session_id): + """Apply a color preset to a session.""" + sid = session_id or get_state().session_id + if not sid: + raise click.UsageError("No session ID specified.") + result = run_iterm2(profile_mod.apply_color_preset, sid, preset_name) + output(result, f"Applied preset '{preset_name}' to session {sid}") + + +# โ”€โ”€ Arrangement group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@cli.group() +def arrangement(): + """Save and restore window arrangements.""" + + +@arrangement.command("list") +@handle_iterm2_error +def arrangement_list(): + """List all saved arrangements.""" + result = run_iterm2(arr_mod.list_arrangements) + output({"arrangements": result}, f"{len(result)} arrangement(s)") + if not _json_output and result: + for a in result: + click.echo(f" {a}") + + +@arrangement.command("save") +@click.argument("name") +@handle_iterm2_error +def arrangement_save(name): + """Save all current windows as a named arrangement.""" + result = run_iterm2(arr_mod.save_arrangement, name) + output(result, f"Saved arrangement '{name}'") + + +@arrangement.command("restore") +@click.argument("name") +@click.option("--window-id", default=None, + help="Restore into an existing window (default: open new windows).") +@handle_iterm2_error +def arrangement_restore(name, window_id): + """Restore a saved arrangement.""" + wid = window_id or None + result = run_iterm2(arr_mod.restore_arrangement, name, window_id=wid) + output(result, f"Restored arrangement '{name}'") + + +@arrangement.command("save-window") +@click.argument("name") +@click.option("--window-id", default=None) +@handle_iterm2_error +def arrangement_save_window(name, window_id): + """Save a single window as a named arrangement.""" + wid = window_id or get_state().window_id + if not wid: + raise click.UsageError("No window ID specified.") + result = run_iterm2(arr_mod.save_window_arrangement, wid, name) + output(result, f"Saved window {wid} as arrangement '{name}'") + + +# โ”€โ”€ Tmux group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@cli.group() +def tmux(): + """Manage iTerm2 tmux integration connections. + + Requires at least one active `tmux -CC` session running inside iTerm2. + Start one with: tmux -CC (new session) + tmux -CC attach (attach to existing) + """ + + +@tmux.command("list") +@handle_iterm2_error +def tmux_list(): + """List all active tmux integration connections.""" + result = run_iterm2(tmux_mod.list_connections) + output({"connections": result}, + f"{len(result)} tmux connection(s)" if result else "No active tmux connections.") + if not _json_output and result: + for c in result: + click.echo(f" {c['connection_id']} " + f"gateway-session={c['owning_session_id']}") + + +@tmux.command("send") +@click.argument("command") +@click.option("--connection-id", default=None, + help="Tmux connection ID (default: first available).") +@handle_iterm2_error +def tmux_send(command, connection_id): + """Send a tmux command to an active connection. + + COMMAND is any valid tmux command, e.g.: + + \b + cli-anything-iterm2 tmux send "list-sessions" + cli-anything-iterm2 tmux send "new-window -n work" + cli-anything-iterm2 tmux send "rename-session dev" + cli-anything-iterm2 tmux send "split-window -h" + """ + result = run_iterm2(tmux_mod.send_command, command, connection_id=connection_id) + output(result, result.get("output", "").strip() or "(no output)") + + +@tmux.command("create-window") +@click.option("--connection-id", default=None, + help="Tmux connection ID (default: first available).") +@click.option("--use-as-context", is_flag=True, default=False, + help="Save new window/session as the current context.") +@handle_iterm2_error +def tmux_create_window(connection_id, use_as_context): + """Create a new tmux window (surfaces as an iTerm2 tab).""" + result = run_iterm2(tmux_mod.create_window, connection_id=connection_id) + if use_as_context: + state = get_state() + state.window_id = result.get("window_id") + state.tab_id = result.get("tab_id") + state.session_id = result.get("session_id") + save_state_now() + output(result, f"Created tmux window: tab={result.get('tab_id')} " + f"session={result.get('session_id')}") + + +@tmux.command("set-visible") +@click.argument("tmux_window_id") +@click.argument("mode", type=click.Choice(["on", "off"])) +@click.option("--connection-id", default=None) +@handle_iterm2_error +def tmux_set_visible(tmux_window_id, mode, connection_id): + """Show or hide a tmux window tab. + + TMUX_WINDOW_ID is the tmux window ID (e.g. @1). Get it from `tmux tabs`. + + \b + cli-anything-iterm2 tmux set-visible @1 off # hide + cli-anything-iterm2 tmux set-visible @1 on # show + """ + visible = mode == "on" + result = run_iterm2(tmux_mod.set_window_visible, tmux_window_id, visible, + connection_id=connection_id) + state_str = "visible" if visible else "hidden" + output(result, f"Tmux window {tmux_window_id} is now {state_str}") + + +@tmux.command("tabs") +@handle_iterm2_error +def tmux_tabs(): + """List all iTerm2 tabs backed by a tmux integration window.""" + result = run_iterm2(tmux_mod.list_tmux_tabs) + output({"tmux_tabs": result}, + f"{len(result)} tmux tab(s)" if result else "No tmux-backed tabs found.") + if not _json_output and result: + for t in result: + click.echo(f" tab={t['tab_id']} tmux-window={t['tmux_window_id']} " + f"connection={t['tmux_connection_id']}") + + +@tmux.command("bootstrap") +@click.option("--attach", is_flag=True, default=False, + help="Run `tmux -CC attach` instead of `tmux -CC`.") +@click.option("--session-id", default=None, + help="Session to send the command to (default: first session).") +@click.option("--timeout", "-t", type=float, default=15.0, + help="Seconds to wait for connection to appear (default 15).") +@handle_iterm2_error +def tmux_bootstrap(attach, session_id, timeout): + """Start a tmux -CC session and wait for the integration to connect. + + Sends `tmux -CC` (or `tmux -CC attach` with --attach) to a terminal + session, then polls until the iTerm2 tmux integration connection appears. + + \b + cli-anything-iterm2 tmux bootstrap # start new session + cli-anything-iterm2 tmux bootstrap --attach # attach to existing + cli-anything-iterm2 tmux bootstrap --session-id w0t0p0 + """ + sid = session_id or get_state().session_id + result = run_iterm2(tmux_mod.bootstrap, attach=attach, + session_id=sid, timeout=timeout) + output(result, f"tmux -CC connected: {result['connection_id']} " + f"({result['elapsed_seconds']}s)") + + +# โ”€โ”€ Broadcast group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@cli.group() +def broadcast(): + """Control broadcast domains (sync keystrokes across panes).""" + + +@broadcast.command("list") +@handle_iterm2_error +def broadcast_list(): + """List current broadcast domains.""" + result = run_iterm2(bcast_mod.get_broadcast_domains) + output({"domains": result}, + f"{len(result)} broadcast domain(s)" if result else "No active broadcast domains.") + if not _json_output and result: + for i, d in enumerate(result, 1): + click.echo(f" domain {i}: {', '.join(d['sessions'])}") + + +@broadcast.command("set") +@click.argument("groups", nargs=-1, required=True) +@handle_iterm2_error +def broadcast_set(groups): + """Set broadcast domains from session ID groups. + + Each argument is a comma-separated list of session IDs forming one domain. + + \b + cli-anything-iterm2 broadcast set "s1,s2" "s3,s4" + """ + domain_groups = [g.split(",") for g in groups] + result = run_iterm2(bcast_mod.set_broadcast_domains, domain_groups) + output(result, f"Set {result['domain_count']} broadcast domain(s)") + + +@broadcast.command("add") +@click.argument("session_ids", nargs=-1, required=True) +@handle_iterm2_error +def broadcast_add(session_ids): + """Add sessions to a new broadcast domain. + + SESSION_IDS: One or more session IDs to group into one domain. + Existing domains are preserved. + + \b + cli-anything-iterm2 broadcast add s1 s2 + """ + result = run_iterm2(bcast_mod.add_to_broadcast, list(session_ids)) + output(result, f"Added {len(session_ids)} session(s) to new broadcast domain") + + +@broadcast.command("clear") +@handle_iterm2_error +def broadcast_clear(): + """Clear all broadcast domains, stopping all input sync.""" + result = run_iterm2(bcast_mod.clear_broadcast) + output(result, "All broadcast domains cleared.") + + +@broadcast.command("all-panes") +@click.option("--window-id", default=None, + help="Scope to a specific window (default: all windows).") +@handle_iterm2_error +def broadcast_all_panes(window_id): + """Sync keystrokes across all panes in all windows (or one window).""" + wid = window_id or get_state().window_id + result = run_iterm2(bcast_mod.broadcast_all_panes, window_id=wid) + output(result, f"Broadcasting to {result['session_count']} session(s)") + + +# โ”€โ”€ Menu group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@cli.group() +def menu(): + """Invoke iTerm2 menu items programmatically.""" + + +@menu.command("select") +@click.argument("identifier") +@handle_iterm2_error +def menu_select(identifier): + """Invoke a menu item by its identifier string. + + IDENTIFIER: e.g. "Shell/Split Vertically with Current Profile" + + Run `menu list-common` to see available identifiers. + """ + result = run_iterm2(menu_mod.select_menu_item, identifier) + output(result, f"Invoked: {identifier}") + + +@menu.command("state") +@click.argument("identifier") +@handle_iterm2_error +def menu_state(identifier): + """Get the checked/enabled state of a menu item.""" + result = run_iterm2(menu_mod.get_menu_item_state, identifier) + output(result, f"{identifier}: checked={result['checked']} enabled={result['enabled']}") + + +@menu.command("list-common") +@handle_iterm2_error +def menu_list_common(): + """List commonly useful menu item identifiers.""" + result = run_iterm2(menu_mod.list_common_menu_items) + output({"menu_items": result}, + f"{len(result)} common menu item(s)") + if not _json_output and result: + for item in result: + click.echo(f" {item['identifier']}") + click.echo(f" {item['description']}") + + +# โ”€โ”€ Pref group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@cli.group() +def pref(): + """Read and write iTerm2 global preferences.""" + + +@pref.command("get") +@click.argument("key") +@handle_iterm2_error +def pref_get(key): + """Get a preference by key name (PreferenceKey enum name or raw string).""" + result = run_iterm2(pref_mod.get_preference, key) + output(result, f"{result['key']} = {result['value']}") + + +@pref.command("set") +@click.argument("key") +@click.argument("value") +@handle_iterm2_error +def pref_set(key, value): + """Set a preference by key name.""" + result = run_iterm2(pref_mod.set_preference, key, value) + output(result, f"Set {result['key']} = {result['value']}") + + +@pref.command("tmux-get") +@handle_iterm2_error +def pref_tmux_get(): + """Show all tmux-related preferences.""" + result = run_iterm2(pref_mod.get_tmux_preferences) + output(result) + if not _json_output: + click.echo(f" open_in: {result['open_tmux_windows_in']} " + f"({result['open_tmux_windows_in_label']})") + click.echo(f" dash_limit: {result['tmux_dashboard_limit']}") + click.echo(f" auto_hide: {result['auto_hide_tmux_client_session']}") + click.echo(f" use_profile:{result['use_tmux_profile']}") + + +@pref.command("tmux-set") +@click.argument("setting", type=click.Choice( + ["open_in", "dashboard_limit", "auto_hide_client", "use_profile"])) +@click.argument("value") +@handle_iterm2_error +def pref_tmux_set(setting, value): + """Set a tmux preference by name. + + \b + open_in: 0=native_windows 1=new_window 2=tabs_in_existing + dashboard_limit: integer + auto_hide_client: true/false + use_profile: true/false + """ + result = run_iterm2(pref_mod.set_tmux_preference, setting, value) + output(result, f"Set tmux.{setting} = {result['value']}") + + +@pref.command("list-keys") +@click.option("--filter", "name_filter", default=None, + help="Filter key names by substring (case-insensitive).") +def pref_list_keys(name_filter): + """List all valid preference key names for use with `pref get/set`. + + \b + cli-anything-iterm2 pref list-keys + cli-anything-iterm2 pref list-keys --filter tmux + cli-anything-iterm2 pref list-keys --filter font + """ + from iterm2.preferences import PreferenceKey + keys = sorted(k.name for k in PreferenceKey) + if name_filter: + keys = [k for k in keys if name_filter.lower() in k.lower()] + data = {"keys": keys, "count": len(keys)} + output(data, f"{len(keys)} preference key(s)") + if not _json_output: + for k in keys: + click.echo(f" {k}") + + +@pref.command("theme") +@handle_iterm2_error +def pref_theme(): + """Get the current iTerm2 theme tags.""" + result = run_iterm2(pref_mod.get_theme) + output(result, f"Theme: {', '.join(result['tags'])} dark={result['is_dark']}") + + +# โ”€โ”€ REPL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@cli.command("repl") +@click.pass_context +def repl(ctx): + """Start the interactive REPL (default when no subcommand given).""" + from cli_anything.iterm2_ctl.utils.repl_skin import ReplSkin + + skin = ReplSkin("iterm2_ctl", version="1.0.0") + skin.print_banner() + + state = get_state() + if state.summary() != "no context set": + skin.info(f"Context: {state.summary()}") + click.echo() + + skin.info("Type 'help' for commands, 'quit' to exit.") + click.echo() + + pt_session = skin.create_prompt_session() + + _COMMANDS = { + "app status": "Show iTerm2 status", + "app current": "Get current window/tab/session", + "app context": "Show saved context", + "app set-context": "Set context IDs", + "app clear-context": "Clear saved context", + "app get-var ": "Get app-level variable", + "app set-var ": "Set app-level variable", + "app alert <subtitle>": "Show modal alert dialog", + "app text-input <title> <subtitle>": "Show text input dialog", + "app file-panel": "Show macOS open file picker", + "app save-panel": "Show macOS save file picker", + "window list": "List open windows", + "window create": "Create a new window", + "window close [id]": "Close a window", + "window activate [id]": "Focus a window", + "window set-title <title>": "Set window title", + "window frame [id]": "Get window geometry", + "window fullscreen <on|off|toggle|status>": "Control fullscreen", + "tab list": "List tabs", + "tab create": "Create a new tab", + "tab close [id]": "Close a tab", + "tab activate [id]": "Focus a tab", + "tab select-pane <dir>": "Move focus to adjacent pane (left/right/above/below)", + "session list": "List sessions", + "session send <text>": "Send text to session", + "session screen": "Read terminal screen", + "session split": "Split pane", + "session close [id]": "Close a session", + "session set-name <name>": "Name a session", + "session resize -c <cols> -r <rows>": "Resize terminal", + "session inject <data>": "Inject raw bytes into session (use --hex for hex string)", + "session get-prompt": "Get last shell prompt info (Shell Integration)", + "session wait-prompt": "Wait for next shell prompt", + "session wait-command-end": "Wait for command to finish", + "session run-tmux-cmd <command>": "Run tmux cmd from gateway session", + "profile list": "List profiles", + "profile get <guid>": "Get profile details by GUID", + "profile color-presets": "List color presets", + "arrangement list": "List arrangements", + "arrangement save <name>": "Save arrangement", + "arrangement restore <name>": "Restore arrangement", + "tmux list": "List active tmux -CC connections", + "tmux bootstrap": "Start tmux -CC and wait for connection", + "tmux send <command>": "Send tmux command (e.g. 'list-sessions')", + "tmux create-window": "Create tmux window as iTerm2 tab", + "tmux set-visible <id> on|off": "Show/hide a tmux window tab", + "tmux tabs": "List tmux-backed tabs", + "broadcast list": "List broadcast domains", + "broadcast set <g1> [g2...]": "Set broadcast domains (comma-sep session IDs)", + "broadcast add <s1> [s2...]": "Add sessions to a new broadcast domain", + "broadcast clear": "Clear all broadcast domains", + "broadcast all-panes": "Broadcast to all panes", + "menu select <identifier>": "Invoke a menu item", + "menu state <identifier>": "Get menu item state", + "menu list-common": "List common menu identifiers", + "pref list-keys": "List all valid preference key names", + "pref get <key>": "Get a preference value", + "pref set <key> <val>": "Set a preference value", + "pref tmux-get": "Show all tmux preferences", + "pref tmux-set <setting> <val>": "Set a tmux preference", + "pref theme": "Show current theme tags", + "help": "Show this help", + "quit": "Exit REPL", + } + + while True: + try: + state = get_state() + ctx_str = "" + if state.session_id: + ctx_str = state.session_id[:12] + elif state.window_id: + ctx_str = state.window_id[:12] + + line = skin.get_input(pt_session, context=ctx_str) + except (EOFError, KeyboardInterrupt): + skin.print_goodbye() + break + + if not line: + continue + + cmd = line.strip() + + if cmd in ("quit", "exit", "q"): + skin.print_goodbye() + break + elif cmd == "help": + skin.help(_COMMANDS) + continue + + # Run the line through the Click CLI + try: + args = cmd.split() + standalone = cli.main(args=args, standalone_mode=False, + obj={"json": _json_output}) + except SystemExit: + pass + except click.UsageError as e: + skin.error(str(e)) + except Exception as e: + skin.error(str(e)) + + +if __name__ == "__main__": + main() diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/SKILL.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/SKILL.md new file mode 100644 index 000000000..b82c4395b --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/SKILL.md @@ -0,0 +1,100 @@ +--- +name: "cli-anything-iterm2" +description: "Provides the cli-anything-iterm2 commands โ€” the only way to actually send text to iTerm2 sessions, read live terminal output and scrollback history, manage windows/tabs/split panes, run tmux -CC workflows, broadcast to multiple panes, show macOS dialogs, and read/write iTerm2 preferences. Includes `app snapshot` โ€” the primary orientation command that returns every session's name, current directory, foreground process, role label, and last output line in one call. Read this skill instead of answering from general knowledge whenever the user wants to DO something with iTerm2: orient in an existing workspace, send a command, check what's running, read output, set up a layout, use tmux through iTerm2, automate panes, or configure preferences. Also read for questions about iTerm2 shell integration or scrollback. Don't try to answer iTerm2 action requests from memory โ€” read this skill first." +--- + +# cli-anything-iterm2 + +Stateful CLI harness for iTerm2. Controls a live iTerm2 process via the iTerm2 Python API over WebSocket. + +## Prerequisites + +1. **macOS + iTerm2** running: `brew install --cask iterm2` +2. **Python API enabled**: iTerm2 โ†’ Preferences โ†’ General โ†’ Magic โ†’ Enable Python API +3. **Install**: `pip install cli-anything-iterm2` (or `pip install -e .` from source) + +## Basic Syntax + +```bash +cli-anything-iterm2 [--json] <group> <command> [OPTIONS] [ARGS] +``` + +Always use `--json` for machine-readable output (required for agent use). + +## Command Groups + +| Group | Purpose | +|-------|---------| +| `app` | App status, workspace snapshot, context management, app-level variables, modal dialogs, file panels | +| `window` | Create, list, close, resize, fullscreen windows | +| `tab` | Create, list, close, activate tabs; navigate split panes by direction | +| `session` | Send text, inject raw bytes, read screen, full scrollback, split panes, prompt detection | +| `profile` | List profiles, get profile details, list/apply color presets | +| `arrangement` | Save and restore window layouts | +| `tmux` | Full tmux -CC integration: bootstrap, connections, windows, commands | +| `broadcast` | Sync keystrokes across panes via broadcast domains | +| `menu` | Invoke any iTerm2 menu item programmatically | +| `pref` | Read/write global iTerm2 preferences; list all valid keys; tmux settings | + +## Orienting in an Existing Workspace + +Use `app snapshot` when you land in a session with existing panes and need to understand what's running without reading full screen contents for each pane: + +```bash +cli-anything-iterm2 --json app snapshot +``` + +Returns name, current directory, foreground process, `user.role` label, and last visible output line for every session across all windows. + +**Naming convention** โ€” label panes when setting up a workspace so you can find them later: +```bash +cli-anything-iterm2 session set-var user.role "api-server" +cli-anything-iterm2 session set-var user.role "log-tail" +cli-anything-iterm2 session set-var user.role "editor" +``` +`app snapshot` will surface these roles alongside process and path, giving you a full picture in one call. + +## Typical Agent Workflow + +```bash +# 1. Orient โ€” snapshot every session: name, path, process, role, last output line +cli-anything-iterm2 --json app snapshot + +# 2. Establish context (saves window/tab/session IDs for subsequent commands) +cli-anything-iterm2 app current + +# 3. Interact โ€” no --session-id needed once context is set +cli-anything-iterm2 session send "git status" +cli-anything-iterm2 --json session scrollback --tail 200 --strip + +# 4. Create a multi-pane workspace โ€” label panes so snapshot identifies them later +cli-anything-iterm2 session split --vertical --use-as-context +cli-anything-iterm2 session send "python3 -m http.server 8000" +cli-anything-iterm2 session set-var user.role "http-server" +``` + +## Reference Files + +Read only what the task requires โ€” each file is a single narrow concern (~10โ€“30 lines): + +| File | Read when you need... | +|------|-----------------------| +| `references/session-io.md` | Send text, inject bytes, read screen/scrollback, get selection | +| `references/session-control.md` | Split panes, activate/close sessions, resize, rename, session variables | +| `references/session-shell-integration.md` | wait-prompt, wait-command-end, get-prompt; reliable sendโ†’waitโ†’read pattern | +| `references/layout-window-tab.md` | Create/close/resize windows and tabs, navigate split panes | +| `references/layout-arrangement.md` | Save and restore window layouts | +| `references/app-context.md` | **Snapshot** (orientation), status, context management, app vars, modal dialogs, file panels | +| `references/profile-pref.md` | Profiles list/get/presets, preferences read/write, tmux pref shortcuts | +| `references/broadcast-menu.md` | Broadcast keystrokes to multiple panes, invoke menu items | +| `references/tmux-commands.md` | All tmux CLI commands (bootstrap, send, tabs, create-window, set-visible) | +| `references/tmux-guide.md` | Full tmux -CC workflow, paneโ†’session ID mapping | +| `references/json-session.md` | `--json` schemas for session, window, tab, screen, scrollback, inject | +| `references/json-tmux-app.md` | `--json` schemas for tmux, app dialogs, preferences, errors | + +## REPL Mode + +Run without arguments for an interactive REPL that maintains context between commands: +```bash +cli-anything-iterm2 +``` diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/app-context.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/app-context.md new file mode 100644 index 000000000..8ed2641d3 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/app-context.md @@ -0,0 +1,39 @@ +# App: Context, Variables, Dialogs, File Panels + +## Workspace orientation +```bash +cli-anything-iterm2 --json app snapshot # rich snapshot: all sessions with path, process, role, last output line +cli-anything-iterm2 --json app status # lightweight inventory: IDs and names only +``` + +`app snapshot` is the preferred orientation command โ€” use it when landing in an existing workspace. +Set `user.role` on panes so snapshot can identify them: `session set-var user.role "api-server"` + +## Context management +```bash +cli-anything-iterm2 --json app status # inventory all windows/tabs/sessions +cli-anything-iterm2 app current # focus โ†’ saves window/tab/session as context +cli-anything-iterm2 app context # show saved context +cli-anything-iterm2 app set-context --session-id <id> +cli-anything-iterm2 app clear-context +``` + +## App-level variables +```bash +cli-anything-iterm2 app get-var hostname +cli-anything-iterm2 app set-var user.myvar hello +``` + +## Modal dialogs +```bash +cli-anything-iterm2 app alert "Title" "Message" +cli-anything-iterm2 app alert "Deploy?" "Push?" --button Yes --button No +cli-anything-iterm2 app text-input "Rename" "Enter name:" --default "myapp" +``` + +## File panels +```bash +cli-anything-iterm2 app file-panel # macOS open picker +cli-anything-iterm2 app file-panel --ext py --ext txt --multi # filter + multi-select +cli-anything-iterm2 app save-panel --filename output.txt # save dialog +``` diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/broadcast-menu.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/broadcast-menu.md new file mode 100644 index 000000000..983a30f80 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/broadcast-menu.md @@ -0,0 +1,25 @@ +# Broadcast & Menu + +## Broadcast โ€” sync keystrokes across panes simultaneously +```bash +cli-anything-iterm2 broadcast list +cli-anything-iterm2 broadcast add <s1> <s2> # group into one domain +cli-anything-iterm2 broadcast set "s1,s2" "s3,s4" # set all domains at once +cli-anything-iterm2 broadcast all-panes [--window-id ID] +cli-anything-iterm2 broadcast clear # stop all broadcasting +``` + +Pattern โ€” run the same command on all panes at once: +```bash +cli-anything-iterm2 broadcast all-panes +cli-anything-iterm2 session send "export ENV=staging" +cli-anything-iterm2 broadcast clear +``` + +## Menu โ€” invoke iTerm2 menu items programmatically +```bash +cli-anything-iterm2 menu list-common +cli-anything-iterm2 menu select "Shell/Split Vertically with Current Profile" +cli-anything-iterm2 menu select "Shell/New Window" +cli-anything-iterm2 menu state "View/Enter Full Screen" # checked + enabled? +``` diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/json-session.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/json-session.md new file mode 100644 index 000000000..ee8d7c70d --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/json-session.md @@ -0,0 +1,49 @@ +# JSON Schemas โ€” Session, Window, Tab + +```json +// app snapshot +{"session_count": 3, "sessions": [ + {"session_id": "...", "name": "api-server", "window_id": "...", "tab_id": "...", + "path": "/Users/alex/project", "pid": 12345, "process": "node", + "role": "api-server", "last_line": "Server listening on :3000"}, + {"session_id": "...", "name": "shell", "window_id": "...", "tab_id": "...", + "path": "/Users/alex", "pid": 67890, "process": "zsh", + "role": null, "last_line": "$ "} +]} + +// app status +{"window_count": 2, "windows": [{"window_id": "...", "tabs": [...]}]} + +// session list +{"sessions": [{"session_id": "...", "name": "...", "tab_id": "...", "window_id": "...", "is_current": false}]} + +// session screen +{"session_id": "...", "total_lines": 40, "returned_lines": 40, "lines": ["$ echo hello", "hello"]} + +// session scrollback +{"session_id": "...", "total_available": 4922, "scrollback_lines": 4862, "screen_lines": 60, + "overflow": 0, "returned_lines": 100, "lines": ["...", "..."]} + +// session wait-command-end +{"session_id": "...", "exit_status": 0, "timed_out": false} + +// session inject +{"session_id": "...", "injected_bytes": 4} + +// tab select-pane +{"tab_id": "...", "direction": "right", "new_session_id": "...", "moved": true} +{"tab_id": "...", "direction": "left", "new_session_id": null, "moved": false} + +// window create +{"window_id": "...", "tab_id": "...", "session_id": "..."} + +// profile get +{"name": "Default", "guid": "...", "badge_text": null} +``` + +## Errors +```bash +Error: Cannot connect to iTerm2. Make sure iTerm2 is running... +Error: Session 'abc123' not found. +``` +With `--json`: `{"error": "Session 'abc123' not found."}` diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/json-tmux-app.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/json-tmux-app.md new file mode 100644 index 000000000..e799b2304 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/json-tmux-app.md @@ -0,0 +1,43 @@ +# JSON Schemas โ€” tmux, App, Preferences + +```json +// tmux list +{"connections": [{"connection_id": "user@host", "owning_session_id": "...", "owning_session_name": "tmux"}]} + +// tmux tabs +{"tmux_tabs": [{"tab_id": "47", "window_id": "pty-...", "tmux_window_id": "0", + "tmux_connection_id": "user@host", "session_count": 1}]} + +// tmux send +{"connection_id": "user@host", "command": "list-sessions", + "output": "0: 3 windows (created ...) (attached)"} + +// tmux bootstrap +{"connection_id": "user@host", "owning_session_id": "...", "command": "tmux -CC", "elapsed_seconds": 0.5} + +// pref tmux-get +{"open_tmux_windows_in": 2, "open_tmux_windows_in_label": "tabs_in_existing", + "tmux_dashboard_limit": 10, "auto_hide_tmux_client_session": true, "use_tmux_profile": false} + +// app alert +{"button_index": 1000, "button_label": "OK"} +// with --button Yes --button No: 1000="Yes", 1001="No" + +// app text-input +{"cancelled": false, "text": "hello world"} +{"cancelled": true, "text": null} + +// app file-panel +{"cancelled": false, "files": ["/Users/alex/foo.py", "/Users/alex/bar.py"]} +{"cancelled": true, "files": []} + +// app save-panel +{"cancelled": false, "file": "/Users/alex/output.txt"} +{"cancelled": true, "file": null} +``` + +## Errors +```bash +Error: No active tmux connections. Start one with: tmux bootstrap +``` +With `--json`: `{"error": "No active tmux connections..."}` diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/layout-arrangement.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/layout-arrangement.md new file mode 100644 index 000000000..15a6de852 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/layout-arrangement.md @@ -0,0 +1,8 @@ +# Arrangements (Saved Layouts) + +```bash +cli-anything-iterm2 arrangement list +cli-anything-iterm2 arrangement save "my-layout" +cli-anything-iterm2 arrangement restore "my-layout" +cli-anything-iterm2 arrangement save-window "window-layout" [--window-id ID] +``` diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/layout-window-tab.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/layout-window-tab.md new file mode 100644 index 000000000..70347a89e --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/layout-window-tab.md @@ -0,0 +1,24 @@ +# Windows & Tabs + +## Windows +```bash +cli-anything-iterm2 window list +cli-anything-iterm2 window create [--profile NAME] [--command CMD] +cli-anything-iterm2 window close [WINDOW_ID] # positional arg, NOT --window-id; uses context if omitted +cli-anything-iterm2 window activate [WINDOW_ID] +cli-anything-iterm2 window set-title "My Window" +cli-anything-iterm2 window frame # get position/size +cli-anything-iterm2 window set-frame --x 0 --y 0 --width 1200 --height 800 +cli-anything-iterm2 window fullscreen on|off|toggle|status +``` + +## Tabs +```bash +cli-anything-iterm2 tab list [--window-id ID] +cli-anything-iterm2 tab create [--window-id ID] [--profile NAME] +cli-anything-iterm2 tab close [TAB_ID] +cli-anything-iterm2 tab activate [TAB_ID] +cli-anything-iterm2 tab info [TAB_ID] +cli-anything-iterm2 tab select-pane right # focus adjacent split pane +cli-anything-iterm2 tab select-pane left|above|below [--tab-id ID] +``` diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/profile-pref.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/profile-pref.md new file mode 100644 index 000000000..24dc6e6aa --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/profile-pref.md @@ -0,0 +1,27 @@ +# Profiles & Preferences + +## Profiles +```bash +cli-anything-iterm2 profile list [--filter NAME] +cli-anything-iterm2 profile get <guid> # detailed settings +cli-anything-iterm2 profile color-presets +cli-anything-iterm2 profile apply-preset "Solarized Dark" [--session-id ID] +``` + +## Preferences +```bash +cli-anything-iterm2 pref list-keys # all valid PreferenceKey names +cli-anything-iterm2 pref list-keys --filter tmux # filter by substring +cli-anything-iterm2 pref get OPEN_TMUX_WINDOWS_IN +cli-anything-iterm2 pref set OPEN_TMUX_WINDOWS_IN 2 +cli-anything-iterm2 pref theme # current theme tags + is_dark bool +``` + +## tmux preferences (shorthand) +```bash +cli-anything-iterm2 pref tmux-get # all tmux prefs at once +cli-anything-iterm2 pref tmux-set open_in 2 # 0=native_windows 1=new_window 2=tabs_in_existing +cli-anything-iterm2 pref tmux-set auto_hide_client true +cli-anything-iterm2 pref tmux-set use_profile true +cli-anything-iterm2 pref tmux-set dashboard_limit 10 +``` diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/session-control.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/session-control.md new file mode 100644 index 000000000..28c753daa --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/session-control.md @@ -0,0 +1,26 @@ +# Session Control + +```bash +# List / activate / close +cli-anything-iterm2 session list [--window-id ID] [--tab-id ID] +cli-anything-iterm2 session activate [SESSION_ID] +cli-anything-iterm2 session close [SESSION_ID] + +# Split panes +cli-anything-iterm2 session split # horizontal split +cli-anything-iterm2 session split --vertical # side-by-side +cli-anything-iterm2 session split --use-as-context # new pane becomes context + +# Metadata +cli-anything-iterm2 session set-name "API Worker" +cli-anything-iterm2 session restart +cli-anything-iterm2 session resize --columns 220 --rows 50 + +# Session variables +# Built-in (read-only): hostname, username, path, pid, columns, rows +cli-anything-iterm2 session get-var hostname +cli-anything-iterm2 session get-var path +# Custom (read/write, must use user. prefix) +cli-anything-iterm2 session set-var user.role "api-worker" +cli-anything-iterm2 session get-var user.role +``` diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/session-io.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/session-io.md new file mode 100644 index 000000000..a68788b81 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/session-io.md @@ -0,0 +1,28 @@ +# Session I/O + +```bash +# Send input +cli-anything-iterm2 session send "echo hello" # sends text + newline +cli-anything-iterm2 session send "text" --session-id <id> +cli-anything-iterm2 session send "text" --no-newline + +# Inject raw bytes +cli-anything-iterm2 session inject $'\x1b[2J' # escape sequence +cli-anything-iterm2 session inject "1b5b324a" --hex # same in hex + +# Read visible screen โ€” ALWAYS use --json, output is silently empty without it +cli-anything-iterm2 --json session screen # visible area only +cli-anything-iterm2 --json session screen --lines 20 + +# Read full history +cli-anything-iterm2 --json session scrollback +cli-anything-iterm2 --json session scrollback --tail 100 +cli-anything-iterm2 --json session scrollback --tail 500 --strip # no null bytes +cli-anything-iterm2 --json session scrollback --lines 200 # first 200 lines + +# Get selected text +cli-anything-iterm2 session selection +``` + +`session screen` = visible area only. `session scrollback` = entire history, atomically, oldestโ†’newest. +`overflow` in scrollback response = lines lost when buffer was full (set profile limit to "unlimited" to avoid). diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/session-shell-integration.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/session-shell-integration.md new file mode 100644 index 000000000..810bdf661 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/session-shell-integration.md @@ -0,0 +1,18 @@ +# Shell Integration + +Requires: `curl -L https://iterm2.com/shell_integration/install_shell_integration.sh | bash` + +```bash +cli-anything-iterm2 session get-prompt # last prompt: command, cwd, state +cli-anything-iterm2 session wait-prompt --timeout 30 # block until next prompt appears +cli-anything-iterm2 session wait-command-end --timeout 120 # block until exit; returns exit_status +``` + +**Reliable execution pattern** (send โ†’ wait โ†’ read): +```bash +cli-anything-iterm2 session send "make build" +cli-anything-iterm2 session wait-command-end --timeout 120 +cli-anything-iterm2 --json session scrollback --tail 50 --strip +``` + +`wait-command-end` returns `{"session_id": "...", "exit_status": 0, "timed_out": false}`. diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/tmux-commands.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/tmux-commands.md new file mode 100644 index 000000000..b48f9741a --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/tmux-commands.md @@ -0,0 +1,24 @@ +# tmux Commands + +```bash +cli-anything-iterm2 tmux bootstrap # start tmux -CC, wait for connection +cli-anything-iterm2 tmux bootstrap --attach # attach to existing session +cli-anything-iterm2 tmux bootstrap --session-id <id> --timeout 15 +cli-anything-iterm2 tmux list # active tmux -CC connections +cli-anything-iterm2 tmux tabs # iTerm2 tabs backed by tmux +cli-anything-iterm2 tmux create-window # new tmux window โ†’ iTerm2 tab +cli-anything-iterm2 tmux create-window --use-as-context +cli-anything-iterm2 tmux set-visible @1 off|on # hide/show a tmux window's tab + +# tmux protocol commands (sent to tmux server, not to a pane) +cli-anything-iterm2 tmux send "list-sessions" +cli-anything-iterm2 tmux send "list-windows -a" +cli-anything-iterm2 tmux send "list-panes -a -F '#{session_name}:#{window_index}:#{pane_index} #{pane_current_command} #{pane_current_path}'" +cli-anything-iterm2 tmux send "new-window -n work" +cli-anything-iterm2 tmux send "rename-session dev" +cli-anything-iterm2 tmux send "split-window -h" +cli-anything-iterm2 tmux send "select-pane -t 0" +cli-anything-iterm2 session run-tmux-cmd "rename-window mywork" +``` + +**Key distinction:** `tmux send` = tmux protocol commands (to tmux server). `session send` = shell text to a specific pane. Use both together. diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/tmux-guide.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/tmux-guide.md new file mode 100644 index 000000000..b14de3eb1 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/skills/references/tmux-guide.md @@ -0,0 +1,37 @@ +# tmux -CC Workflow Guide + +tmux -CC renders each tmux window as a native iTerm2 tab โ€” fully visible, readable, and controllable. + +## Full workflow +```bash +# 1. Set context to the target session BEFORE bootstrapping โ€” otherwise bootstrap times out +cli-anything-iterm2 app set-context --session-id <id> + +# 2. Bootstrap +cli-anything-iterm2 --json tmux bootstrap + +# 2. Enumerate +cli-anything-iterm2 --json tmux send "list-sessions" +cli-anything-iterm2 --json tmux send "list-panes -a -F '#{session_name}:#{window_index}:#{pane_index} #{pane_current_command} #{pane_current_path}'" +cli-anything-iterm2 --json tmux tabs # maps tmux windows โ†’ iTerm2 tab IDs + +# 3. Read any pane +cli-anything-iterm2 --json session screen --session-id <pane-session-id> +cli-anything-iterm2 --json session scrollback --session-id <pane-session-id> --tail 500 --strip + +# 4. Send to any pane +cli-anything-iterm2 session send "git log --oneline -10" --session-id <pane-session-id> + +# 5. Manage layout +cli-anything-iterm2 tmux send "new-window -n logs" +cli-anything-iterm2 tmux send "split-window -h -t logs" +cli-anything-iterm2 tmux send "select-layout -t logs even-horizontal" +cli-anything-iterm2 tmux create-window --use-as-context +``` + +## Mapping tmux panes โ†’ iTerm2 session IDs +tmux panes don't directly expose iTerm2 session IDs. Cross-reference: +```bash +cli-anything-iterm2 --json tmux tabs # โ†’ tab_id per tmux window +cli-anything-iterm2 --json app status # โ†’ session_id per tab_id +``` diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/tests/TEST.md b/iterm2/agent-harness/cli_anything/iterm2_ctl/tests/TEST.md new file mode 100644 index 000000000..2fc9455e9 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/tests/TEST.md @@ -0,0 +1,334 @@ +# Test Plan โ€” cli-anything-iterm2 + +## Test Inventory Plan + +| File | Tests Planned | +|------|--------------| +| `test_core.py` | 28 unit tests | +| `test_full_e2e.py` | 18 E2E + subprocess tests | + +--- + +## Unit Test Plan (test_core.py) + +### Module: `core/session_state.py` + +Functions to test: `SessionState`, `load_state`, `save_state`, `clear_state` + +| Test | Description | +|------|-------------| +| `test_session_state_defaults` | Default state has all None fields | +| `test_session_state_summary_empty` | Empty state returns "no context set" | +| `test_session_state_summary_partial` | Partial state describes available fields | +| `test_session_state_summary_full` | Full state lists all three IDs | +| `test_session_state_to_dict` | Serializes to expected dict shape | +| `test_session_state_from_dict` | Deserializes correctly | +| `test_session_state_from_dict_missing_keys` | Missing keys use defaults | +| `test_session_state_clear` | Clear sets all fields to None | +| `test_save_and_load_state` | Round-trip save + load | +| `test_save_state_creates_dir` | Creates parent dir if missing | +| `test_load_state_missing_file` | Returns empty state for missing file | +| `test_load_state_invalid_json` | Returns empty state for corrupt JSON | +| `test_clear_state` | Persists empty state to disk | +| `test_save_state_overwrite` | Overwrites existing state | + +### Module: `utils/iterm2_backend.py` + +Functions to test: `find_iterm2_app`, `require_iterm2_running`, `connection_error_help` + +| Test | Description | +|------|-------------| +| `test_find_iterm2_app_present` | Returns path when iTerm2 exists at known location | +| `test_find_iterm2_app_absent` | Raises RuntimeError with install instructions | +| `test_require_iterm2_running_import_error` | Raises RuntimeError if iterm2 not installed | +| `test_connection_error_help_content` | Returns helpful instructions string | + +### Module: `core/session.py` (logic only โ€” no live connection) + +| Test | Description | +|------|-------------| +| `test_send_text_builds_payload_with_newline` | Default adds \\n to text | +| `test_send_text_no_newline_flag` | --no-newline flag prevents \\n | + +### Module: CLI output formatting + +| Test | Description | +|------|-------------| +| `test_cli_help` | `--help` exits 0 and mentions groups | +| `test_cli_app_help` | `app --help` shows subcommands | +| `test_cli_window_help` | `window --help` shows subcommands | +| `test_cli_tab_help` | `tab --help` shows subcommands | +| `test_cli_session_help` | `session --help` shows subcommands | +| `test_cli_profile_help` | `profile --help` shows subcommands | +| `test_cli_arrangement_help` | `arrangement --help` shows subcommands | +| `test_json_flag_propagates` | `--json` flag is recognized | + +--- + +## E2E Test Plan (test_full_e2e.py) + +**Prerequisite:** iTerm2 must be running with Python API enabled. + +### App status workflow + +| Test | Description | Verifies | +|------|-------------|---------| +| `test_app_status` | Get iTerm2 app status | Returns window count โ‰ฅ 0 | +| `test_app_current` | Get current focused context | Returns window/tab/session IDs | + +### Window workflow + +| Test | Description | Verifies | +|------|-------------|---------| +| `test_window_create_and_close` | Create window, verify it appears in list, close it | Window ID in list, removed after close | +| `test_window_create_with_profile` | Create window with Default profile | Returns valid window_id | +| `test_window_set_title` | Set window title | No error | +| `test_window_frame` | Get and set window frame | Returns numeric x/y/w/h | + +### Tab workflow + +| Test | Description | Verifies | +|------|-------------|---------| +| `test_tab_create_and_close` | Create tab in window, close it | tab_id present, removed | +| `test_tab_list` | List tabs | Returns list | + +### Session workflow + +| Test | Description | Verifies | +|------|-------------|---------| +| `test_session_list` | List all sessions | Returns non-empty list | +| `test_session_send_and_screen` | Send a command, read screen | Output contains sent command or its result | +| `test_session_split_and_close` | Split pane, close new pane | new_session_id returned | + +### Profile workflow + +| Test | Description | Verifies | +|------|-------------|---------| +| `test_profile_list` | List profiles | Returns โ‰ฅ 1 profile | +| `test_color_presets` | List color presets | Returns list of strings | + +### Arrangement workflow + +| Test | Description | Verifies | +|------|-------------|---------| +| `test_arrangement_save_restore_list` | Save, list, restore arrangement | Name appears in list | + +### CLI subprocess tests + +Uses `_resolve_cli("cli-anything-iterm2")` โ€” runs installed command as a real user would. + +| Test | Description | Verifies | +|------|-------------|---------| +| `test_cli_help` | `cli-anything-iterm2 --help` | exit code 0 | +| `test_cli_json_app_status` | `--json app status` | Valid JSON with window_count | +| `test_cli_json_window_list` | `--json window list` | Valid JSON list | +| `test_cli_json_session_list` | `--json session list` | Valid JSON list | + +--- + +## Realistic Workflow Scenarios + +### Workflow 1: Agent workspace setup +**Simulates:** AI agent preparing a multi-pane development workspace + +**Operations:** +1. `app current` โ€” discover focused window +2. `window create` โ€” open fresh window +3. `session split --vertical` โ€” create side-by-side panes +4. `session send "cd ~/Developer" --session-id <left>` โ€” navigate in left pane +5. `session send "python3 -m http.server 8000" --session-id <right>` โ€” start server in right +6. `session screen --session-id <right>` โ€” verify server started + +**Verified:** Two sessions exist, screen contains server output + +### Workflow 2: Automation audit +**Simulates:** Agent reading terminal state without modifying it + +**Operations:** +1. `app status` โ€” inventory all windows/tabs/sessions +2. `session screen` โ€” read each session's visible output +3. `session get-var hostname` โ€” check which host each session is on + +**Verified:** JSON output for all commands, parseable by agent + +### Workflow 3: Layout save/restore +**Simulates:** Saving a working environment and restoring it later + +**Operations:** +1. Create 2 windows, each with 2 tabs +2. `arrangement save "dev-env"` โ€” snapshot layout +3. `arrangement list` โ€” verify it appears +4. Close all new windows +5. `arrangement restore "dev-env"` โ€” restore windows + +**Verified:** Arrangement name in list, windows restored + +--- + +## Test Results + +### Run: Phase 6 (2026-03-22) + +**Command:** +```bash +CLI_ANYTHING_FORCE_INSTALLED=1 python3.12 -m pytest cli_anything/iterm2_ctl/tests/ -v --tb=no +``` + +**[_resolve_cli] Using installed command: /opt/homebrew/bin/cli-anything-iterm2** + +``` +============================= test session starts ============================== +platform darwin -- Python 3.12.13, pytest-9.0.2, pluggy-1.6.0 +rootdir: /Users/alexanderbass/Developer/iTerm2-master/agent-harness + +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStateDefaults::test_clear PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStateDefaults::test_defaults PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStateDefaults::test_from_dict PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStateDefaults::test_from_dict_missing_keys PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStateDefaults::test_summary_empty PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStateDefaults::test_summary_full PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStateDefaults::test_summary_partial_tab_only PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStateDefaults::test_summary_partial_window_only PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStateDefaults::test_to_dict PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStatePersistence::test_clear_state PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStatePersistence::test_load_invalid_json_returns_empty PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStatePersistence::test_load_missing_file_returns_empty PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStatePersistence::test_overwrite_existing_state PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStatePersistence::test_save_and_load_roundtrip PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStatePersistence::test_save_creates_parent_dir PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestSessionStatePersistence::test_saved_file_is_valid_json PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestIterm2Backend::test_connection_error_help_content PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestIterm2Backend::test_find_iterm2_app_absent PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestIterm2Backend::test_find_iterm2_app_present PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestIterm2Backend::test_require_iterm2_running_import_error PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestCLIHelp::test_app_help PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestCLIHelp::test_arrangement_help PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestCLIHelp::test_json_flag_in_help PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestCLIHelp::test_main_help PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestCLIHelp::test_profile_help PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestCLIHelp::test_session_help PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestCLIHelp::test_session_send_help PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestCLIHelp::test_session_split_help PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestCLIHelp::test_tab_help PASSED +cli_anything/iterm2_ctl/tests/test_core.py::TestCLIHelp::test_window_help PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestAppStatus::test_app_status PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestAppStatus::test_get_current_context PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestWindowOperations::test_list_windows PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestWindowOperations::test_create_and_close_window PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestWindowOperations::test_window_frame PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestTabOperations::test_list_tabs PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestTabOperations::test_create_and_close_tab PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestSessionOperations::test_list_sessions PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestSessionOperations::test_send_text_and_read_screen PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestSessionOperations::test_split_pane PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestProfileOperations::test_list_profiles PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestProfileOperations::test_list_color_presets PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestArrangementOperations::test_arrangement_save_list_restore PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestCLISubprocess::test_help PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestCLISubprocess::test_json_app_status PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestCLISubprocess::test_json_window_list PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestCLISubprocess::test_json_session_list PASSED +cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestCLISubprocess::test_json_profile_list PASSED + +======================= 48 passed, 17 warnings in 3.22s ======================== +``` + +### Summary + +| Metric | Value | +|--------|-------| +| Total tests | 48 | +| Pass rate | 100% (48/48) | +| Execution time | 3.22s | +| Unit tests | 30 (no iTerm2 needed) | +| E2E tests | 18 (live iTerm2 connection) | +| Subprocess tests | 4 (using installed `cli-anything-iterm2`) | + +### Run: Phase 6 โ€” After tmux additions (2026-03-22) + +**Command:** +```bash +CLI_ANYTHING_FORCE_INSTALLED=1 python3.12 -m pytest cli_anything/iterm2_ctl/tests/ -v --tb=short +``` + +``` +==================== 68 passed, 5 skipped, 17 warnings in 3.84s ==================== +``` + +The 5 skipped tests are `TestTmuxOperations` tests that require an active `tmux -CC` +integration session. They are correctly skipped when none is running and will execute +fully when a session is active. + +### Summary (after tmux) + +| Metric | Value | +|--------|-------| +| Total tests | 73 | +| Passing | 68 | +| Skipped (tmux -CC not running) | 5 | +| Pass rate | 100% of executed tests | +| Execution time | 3.84s | +| Unit tests | 44 (no iTerm2 needed) | +| E2E tests | 29 (live iTerm2 connection) | +| Subprocess tests | 6 (using installed `cli-anything-iterm2`) | + +### Coverage Notes + +- **All core modules** covered by unit tests: `session_state`, `iterm2_backend`, `tmux` (7 logic tests with mocks), CLI help/structure +- **All command groups** covered by E2E tests: app, window, tab, session, profile, arrangement, tmux +- **Tmux logic tests** cover: empty connection list, unknown ID error, first-connection selection, `list_connections` formatting, `send_command` output, `set_window_visible` argument passing +- **Tmux E2E tests** (skipped without `tmux -CC`): `list-sessions`, `list-windows`, `display-message`, create-window, set-visible roundtrip +- **Subprocess tests** confirm `--json tmux list`, `--json tmux tabs`, `tmux --help`, and `tmux send` error path all work via the installed command +- **Not covered**: `async_inject`, `async_get_selection_text`, broadcast domains โ€” less commonly used operations +- **Warnings**: `iterm2` package uses deprecated `enum.Enum` nested-class pattern (Python 3.12 issue in the pypi package) โ€” no functional impact + +### Running tmux tests with an active connection + +To run the full tmux test suite, start a `tmux -CC` session in iTerm2: +```bash +tmux -CC # in an iTerm2 terminal (not a subprocess) +# then in another tab: +CLI_ANYTHING_FORCE_INSTALLED=1 python3.12 -m pytest cli_anything/iterm2_ctl/tests/test_full_e2e.py::TestTmuxOperations -v -s +``` + +--- + +### Run: Phase 7 โ€” Full capability expansion (2026-03-22) + +**New capabilities added:** +- `broadcast` group: list, set, add, clear, all-panes +- `menu` group: select, state, list-common +- `pref` group: get, set, tmux-get, tmux-set, theme +- `tmux bootstrap` command (start tmux -CC and wait for connection) +- `session get-prompt`, `wait-prompt`, `wait-command-end` (Shell Integration) +- `app get-var`, `app set-var` +- `core/broadcast.py`, `core/menu.py`, `core/pref.py`, `core/prompt.py` + +**Command:** +```bash +CLI_ANYTHING_FORCE_INSTALLED=1 python3.12 -m pytest cli_anything/iterm2_ctl/tests/ -v --tb=short +``` + +``` +==================== 104 passed, 5 skipped, 17 warnings in 4.40s ==================== +``` + +**Summary (Phase 7):** + +| Metric | Value | +|--------|-------| +| Total tests | 109 | +| Passing | 104 | +| Skipped (tmux -CC not running) | 5 | +| Pass rate | 100% of executed tests | +| Execution time | ~4.4s | +| Unit tests | 80 (no iTerm2 needed) | +| E2E tests | 29 (live iTerm2 connection) | + +**New unit test classes:** +- `TestCLIHelp` extended: 20 new help-structure tests for broadcast, menu, pref, tmux bootstrap, session prompt, app vars +- `TestBroadcastCore`: empty domains, clear calls API +- `TestMenuCore`: list_common structure, select_menu_item calls API +- `TestPrefCore`: _parse_value (bool/int/float/str), unknown setting raises +- `TestPromptCore`: None prompt, mock prompt dict, get_last_prompt None, list_prompts empty +- `TestTmuxBootstrap`: timeout raises, no windows raises diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/tests/__init__.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/tests/test_core.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/tests/test_core.py new file mode 100644 index 000000000..cd5aa95b5 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/tests/test_core.py @@ -0,0 +1,1085 @@ +"""Unit tests for cli-anything-iterm2 core modules. + +These tests use synthetic data and do NOT require iTerm2 to be running. +All tests are deterministic and have no external dependencies. +""" +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +# Ensure the package is importable from the agent-harness directory +_HARNESS = Path(__file__).resolve().parents[4] +if str(_HARNESS) not in sys.path: + sys.path.insert(0, str(_HARNESS)) + +from cli_anything.iterm2_ctl.core.session_state import ( + SessionState, + clear_state, + load_state, + save_state, +) + + +# โ”€โ”€ SessionState tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestSessionStateDefaults(unittest.TestCase): + def test_defaults(self): + s = SessionState() + self.assertIsNone(s.window_id) + self.assertIsNone(s.tab_id) + self.assertIsNone(s.session_id) + self.assertEqual(s.notes, "") + + def test_summary_empty(self): + s = SessionState() + self.assertEqual(s.summary(), "no context set") + + def test_summary_partial_window_only(self): + s = SessionState(window_id="w1") + self.assertIn("window=w1", s.summary()) + + def test_summary_partial_tab_only(self): + s = SessionState(tab_id="t1") + self.assertIn("tab=t1", s.summary()) + + def test_summary_full(self): + s = SessionState(window_id="w1", tab_id="t1", session_id="s1") + summary = s.summary() + self.assertIn("window=w1", summary) + self.assertIn("tab=t1", summary) + self.assertIn("session=s1", summary) + + def test_to_dict(self): + s = SessionState(window_id="w1", tab_id="t1", session_id="s1", notes="test") + d = s.to_dict() + self.assertEqual(d["window_id"], "w1") + self.assertEqual(d["tab_id"], "t1") + self.assertEqual(d["session_id"], "s1") + self.assertEqual(d["notes"], "test") + + def test_from_dict(self): + d = {"window_id": "w2", "tab_id": "t2", "session_id": "s2", "notes": "hi"} + s = SessionState.from_dict(d) + self.assertEqual(s.window_id, "w2") + self.assertEqual(s.tab_id, "t2") + self.assertEqual(s.session_id, "s2") + self.assertEqual(s.notes, "hi") + + def test_from_dict_missing_keys(self): + s = SessionState.from_dict({}) + self.assertIsNone(s.window_id) + self.assertIsNone(s.tab_id) + self.assertIsNone(s.session_id) + self.assertEqual(s.notes, "") + + def test_clear(self): + s = SessionState(window_id="w1", tab_id="t1", session_id="s1") + s.clear() + self.assertIsNone(s.window_id) + self.assertIsNone(s.tab_id) + self.assertIsNone(s.session_id) + + +# โ”€โ”€ File persistence tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestSessionStatePersistence(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = os.path.join(self.tmp.name, "session.json") + + def tearDown(self): + self.tmp.cleanup() + + def test_save_and_load_roundtrip(self): + s = SessionState(window_id="w1", tab_id="t1", session_id="s1") + save_state(s, self.path) + loaded = load_state(self.path) + self.assertEqual(loaded.window_id, "w1") + self.assertEqual(loaded.tab_id, "t1") + self.assertEqual(loaded.session_id, "s1") + + def test_save_creates_parent_dir(self): + nested = os.path.join(self.tmp.name, "deep", "nested", "session.json") + s = SessionState(window_id="w99") + save_state(s, nested) + self.assertTrue(os.path.exists(nested)) + + def test_load_missing_file_returns_empty(self): + missing = os.path.join(self.tmp.name, "nonexistent.json") + s = load_state(missing) + self.assertIsNone(s.window_id) + + def test_load_invalid_json_returns_empty(self): + bad_path = os.path.join(self.tmp.name, "bad.json") + with open(bad_path, "w") as f: + f.write("NOT JSON {{{{") + s = load_state(bad_path) + self.assertIsNone(s.window_id) + + def test_clear_state(self): + s = SessionState(window_id="w1") + save_state(s, self.path) + clear_state(self.path) + loaded = load_state(self.path) + self.assertIsNone(loaded.window_id) + + def test_overwrite_existing_state(self): + s1 = SessionState(window_id="w1") + save_state(s1, self.path) + s2 = SessionState(window_id="w2", session_id="s2") + save_state(s2, self.path) + loaded = load_state(self.path) + self.assertEqual(loaded.window_id, "w2") + self.assertEqual(loaded.session_id, "s2") + + def test_saved_file_is_valid_json(self): + s = SessionState(window_id="w1", tab_id="t1") + save_state(s, self.path) + with open(self.path) as f: + data = json.load(f) + self.assertEqual(data["window_id"], "w1") + + +# โ”€โ”€ Backend utility tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestIterm2Backend(unittest.TestCase): + def test_find_iterm2_app_absent(self): + from cli_anything.iterm2_ctl.utils.iterm2_backend import find_iterm2_app + with patch("os.path.isdir", return_value=False): + with self.assertRaises(RuntimeError) as ctx: + find_iterm2_app() + self.assertIn("iTerm2", str(ctx.exception)) + self.assertIn("iterm2.com", str(ctx.exception)) + + def test_find_iterm2_app_present(self): + from cli_anything.iterm2_ctl.utils.iterm2_backend import find_iterm2_app + with patch("os.path.isdir", return_value=True): + path = find_iterm2_app() + self.assertIn("iTerm", path) + + def test_require_iterm2_running_import_error(self): + from cli_anything.iterm2_ctl.utils.iterm2_backend import require_iterm2_running + with patch("builtins.__import__", side_effect=ImportError("no module")): + with self.assertRaises((RuntimeError, ImportError)): + require_iterm2_running() + + def test_connection_error_help_content(self): + from cli_anything.iterm2_ctl.utils.iterm2_backend import connection_error_help + help_text = connection_error_help() + self.assertIn("iTerm2", help_text) + self.assertIn("Python API", help_text) + + +# โ”€โ”€ CLI help / structural tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestCLIHelp(unittest.TestCase): + """Verify CLI structure without requiring iTerm2 connection.""" + + def _invoke(self, args): + from click.testing import CliRunner + from cli_anything.iterm2_ctl.iterm2_ctl_cli import cli + runner = CliRunner() + return runner.invoke(cli, args) + + def test_main_help(self): + result = self._invoke(["--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("iterm2", result.output.lower()) + + def test_app_help(self): + result = self._invoke(["app", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("status", result.output) + + def test_window_help(self): + result = self._invoke(["window", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("create", result.output) + self.assertIn("list", result.output) + + def test_tab_help(self): + result = self._invoke(["tab", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("create", result.output) + + def test_session_help(self): + result = self._invoke(["session", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("send", result.output) + self.assertIn("screen", result.output) + + def test_profile_help(self): + result = self._invoke(["profile", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("list", result.output) + + def test_arrangement_help(self): + result = self._invoke(["arrangement", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("save", result.output) + self.assertIn("restore", result.output) + + def test_json_flag_in_help(self): + result = self._invoke(["--help"]) + self.assertIn("json", result.output.lower()) + + def test_session_send_help(self): + result = self._invoke(["session", "send", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("text", result.output.lower()) + + def test_session_split_help(self): + result = self._invoke(["session", "split", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("vertical", result.output.lower()) + + def test_tmux_help(self): + result = self._invoke(["tmux", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("list", result.output) + self.assertIn("send", result.output) + + def test_tmux_list_help(self): + result = self._invoke(["tmux", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_tmux_send_help(self): + result = self._invoke(["tmux", "send", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("command", result.output.lower()) + + def test_tmux_create_window_help(self): + result = self._invoke(["tmux", "create-window", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_tmux_set_visible_help(self): + result = self._invoke(["tmux", "set-visible", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("on", result.output) + self.assertIn("off", result.output) + + def test_tmux_tabs_help(self): + result = self._invoke(["tmux", "tabs", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_session_run_tmux_cmd_help(self): + result = self._invoke(["session", "run-tmux-cmd", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("command", result.output.lower()) + + def test_session_get_prompt_help(self): + result = self._invoke(["session", "get-prompt", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_session_wait_prompt_help(self): + result = self._invoke(["session", "wait-prompt", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("timeout", result.output.lower()) + + def test_session_wait_command_end_help(self): + result = self._invoke(["session", "wait-command-end", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_app_get_var_help(self): + result = self._invoke(["app", "get-var", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_app_set_var_help(self): + result = self._invoke(["app", "set-var", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_broadcast_help(self): + result = self._invoke(["broadcast", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("list", result.output) + self.assertIn("clear", result.output) + + def test_broadcast_list_help(self): + result = self._invoke(["broadcast", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_broadcast_set_help(self): + result = self._invoke(["broadcast", "set", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_broadcast_add_help(self): + result = self._invoke(["broadcast", "add", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_broadcast_all_panes_help(self): + result = self._invoke(["broadcast", "all-panes", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_menu_help(self): + result = self._invoke(["menu", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("select", result.output) + self.assertIn("list-common", result.output) + + def test_menu_select_help(self): + result = self._invoke(["menu", "select", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("identifier", result.output.lower()) + + def test_menu_list_common_help(self): + result = self._invoke(["menu", "list-common", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_pref_help(self): + result = self._invoke(["pref", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("get", result.output) + self.assertIn("set", result.output) + self.assertIn("tmux-get", result.output) + + def test_pref_get_help(self): + result = self._invoke(["pref", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_pref_tmux_get_help(self): + result = self._invoke(["pref", "tmux-get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_pref_tmux_set_help(self): + result = self._invoke(["pref", "tmux-set", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("open_in", result.output) + + def test_pref_theme_help(self): + result = self._invoke(["pref", "theme", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_tmux_bootstrap_help(self): + result = self._invoke(["tmux", "bootstrap", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("attach", result.output.lower()) + + +# โ”€โ”€ Tmux core logic tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestTmuxCore(unittest.TestCase): + """Unit tests for core/tmux.py logic that doesn't need a live connection.""" + + def test_resolve_connection_empty_raises(self): + """_resolve_connection raises RuntimeError when no connections exist.""" + import asyncio + from unittest.mock import AsyncMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.tmux import _resolve_connection + + mock_conn = MagicMock() + + with patch( + "cli_anything.iterm2_ctl.core.tmux._ensure_app_and_connections", + new=AsyncMock(return_value=[]), + ): + with self.assertRaises(RuntimeError) as ctx: + await _resolve_connection(mock_conn, None) + self.assertIn("tmux -CC", str(ctx.exception)) + + asyncio.run(_run()) + + def test_resolve_connection_by_id_not_found(self): + """_resolve_connection raises ValueError for unknown connection ID.""" + import asyncio + from unittest.mock import AsyncMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.tmux import _resolve_connection + + mock_conn_obj = MagicMock() + mock_conn_obj.connection_id = "real-id" + + with patch( + "cli_anything.iterm2_ctl.core.tmux._ensure_app_and_connections", + new=AsyncMock(return_value=[mock_conn_obj]), + ): + with self.assertRaises(ValueError) as ctx: + await _resolve_connection(MagicMock(), "wrong-id") + self.assertIn("real-id", str(ctx.exception)) + + asyncio.run(_run()) + + def test_resolve_connection_returns_first_when_no_id(self): + """_resolve_connection returns the first connection when ID is None.""" + import asyncio + from unittest.mock import AsyncMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.tmux import _resolve_connection + + c1 = MagicMock() + c1.connection_id = "conn-1" + c2 = MagicMock() + c2.connection_id = "conn-2" + + with patch( + "cli_anything.iterm2_ctl.core.tmux._ensure_app_and_connections", + new=AsyncMock(return_value=[c1, c2]), + ): + result = await _resolve_connection(MagicMock(), None) + self.assertEqual(result.connection_id, "conn-1") + + asyncio.run(_run()) + + def test_list_connections_empty(self): + """list_connections returns empty list when no tmux connections.""" + import asyncio + from unittest.mock import AsyncMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.tmux import list_connections + + with patch( + "cli_anything.iterm2_ctl.core.tmux._ensure_app_and_connections", + new=AsyncMock(return_value=[]), + ): + result = await list_connections(MagicMock()) + self.assertEqual(result, []) + + asyncio.run(_run()) + + def test_list_connections_formats_result(self): + """list_connections returns dicts with expected keys.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.tmux import list_connections + + mock_session = MagicMock() + mock_session.session_id = "sess-1" + mock_session.name = "bash" + + mock_conn = MagicMock() + mock_conn.connection_id = "user@host" + mock_conn.owning_session = mock_session + + with patch( + "cli_anything.iterm2_ctl.core.tmux._ensure_app_and_connections", + new=AsyncMock(return_value=[mock_conn]), + ): + result = await list_connections(MagicMock()) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["connection_id"], "user@host") + self.assertEqual(result[0]["owning_session_id"], "sess-1") + self.assertEqual(result[0]["owning_session_name"], "bash") + + asyncio.run(_run()) + + def test_send_command_returns_output(self): + """send_command returns connection_id, command, and output.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.tmux import send_command + + mock_tc = MagicMock() + mock_tc.connection_id = "user@host" + mock_tc.async_send_command = AsyncMock(return_value="session1\nsession2\n") + + with patch( + "cli_anything.iterm2_ctl.core.tmux._resolve_connection", + new=AsyncMock(return_value=mock_tc), + ): + result = await send_command(MagicMock(), "list-sessions") + self.assertEqual(result["command"], "list-sessions") + self.assertEqual(result["output"], "session1\nsession2\n") + self.assertEqual(result["connection_id"], "user@host") + + asyncio.run(_run()) + + def test_set_window_visible_on(self): + """set_window_visible calls async_set_tmux_window_visible with correct args.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.tmux import set_window_visible + + mock_tc = MagicMock() + mock_tc.connection_id = "user@host" + mock_tc.async_set_tmux_window_visible = AsyncMock() + + with patch( + "cli_anything.iterm2_ctl.core.tmux._resolve_connection", + new=AsyncMock(return_value=mock_tc), + ): + result = await set_window_visible(MagicMock(), "@1", True) + mock_tc.async_set_tmux_window_visible.assert_awaited_once_with("@1", True) + self.assertEqual(result["tmux_window_id"], "@1") + self.assertTrue(result["visible"]) + + asyncio.run(_run()) + + +# โ”€โ”€ Broadcast core logic tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestBroadcastCore(unittest.TestCase): + """Unit tests for core/broadcast.py.""" + + def test_get_broadcast_domains_empty(self): + """get_broadcast_domains returns empty list when no domains active.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.broadcast import get_broadcast_domains + + mock_app = MagicMock() + mock_app.async_refresh_broadcast_domains = AsyncMock() + mock_app.broadcast_domains = [] + + with patch( + "iterm2.async_get_app", + new=AsyncMock(return_value=mock_app), + ): + result = await get_broadcast_domains(MagicMock()) + self.assertEqual(result, []) + + asyncio.run(_run()) + + def test_clear_broadcast_calls_set(self): + """clear_broadcast calls async_set_broadcast_domains with empty list.""" + import asyncio + from unittest.mock import AsyncMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.broadcast import clear_broadcast + + with patch( + "iterm2.async_set_broadcast_domains", + new=AsyncMock(), + ) as mock_set: + result = await clear_broadcast(MagicMock()) + mock_set.assert_awaited_once() + self.assertEqual(result["domains"], []) + self.assertTrue(result["cleared"]) + + asyncio.run(_run()) + + +# โ”€โ”€ Menu core logic tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestMenuCore(unittest.TestCase): + """Unit tests for core/menu.py.""" + + def test_list_common_menu_items_structure(self): + """list_common_menu_items returns list of dicts with identifier+description.""" + import asyncio + + async def _run(): + from cli_anything.iterm2_ctl.core.menu import list_common_menu_items + result = await list_common_menu_items(MagicMock()) + self.assertIsInstance(result, list) + self.assertGreater(len(result), 0) + for item in result: + self.assertIn("identifier", item) + self.assertIn("description", item) + + asyncio.run(_run()) + + def test_select_menu_item_calls_api(self): + """select_menu_item calls MainMenu.async_select_menu_item.""" + import asyncio + from unittest.mock import AsyncMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.menu import select_menu_item + + with patch( + "iterm2.MainMenu.async_select_menu_item", + new=AsyncMock(), + ) as mock_select: + result = await select_menu_item(MagicMock(), "Shell/New Window") + mock_select.assert_awaited_once() + self.assertTrue(result["invoked"]) + self.assertEqual(result["identifier"], "Shell/New Window") + + asyncio.run(_run()) + + +# โ”€โ”€ Pref core logic tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestPrefCore(unittest.TestCase): + """Unit tests for core/pref.py.""" + + def test_parse_value_bool_true(self): + from cli_anything.iterm2_ctl.core.pref import _parse_value + self.assertTrue(_parse_value("true")) + self.assertTrue(_parse_value("True")) + self.assertTrue(_parse_value("TRUE")) + + def test_parse_value_bool_false(self): + from cli_anything.iterm2_ctl.core.pref import _parse_value + self.assertFalse(_parse_value("false")) + + def test_parse_value_int(self): + from cli_anything.iterm2_ctl.core.pref import _parse_value + self.assertEqual(_parse_value("42"), 42) + self.assertIsInstance(_parse_value("42"), int) + + def test_parse_value_float(self): + from cli_anything.iterm2_ctl.core.pref import _parse_value + self.assertAlmostEqual(_parse_value("3.14"), 3.14) + + def test_parse_value_string(self): + from cli_anything.iterm2_ctl.core.pref import _parse_value + self.assertEqual(_parse_value("hello"), "hello") + + def test_parse_value_passthrough_non_string(self): + from cli_anything.iterm2_ctl.core.pref import _parse_value + self.assertEqual(_parse_value(42), 42) + + def test_set_tmux_preference_unknown_setting(self): + """set_tmux_preference raises ValueError for unknown setting name.""" + import asyncio + + async def _run(): + from cli_anything.iterm2_ctl.core.pref import set_tmux_preference + with self.assertRaises(ValueError) as ctx: + await set_tmux_preference(MagicMock(), "nonexistent_setting", "1") + self.assertIn("nonexistent_setting", str(ctx.exception)) + + asyncio.run(_run()) + + +# โ”€โ”€ Prompt core logic tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestPromptCore(unittest.TestCase): + """Unit tests for core/prompt.py.""" + + def test_prompt_to_dict_none(self): + """_prompt_to_dict handles None (Shell Integration absent).""" + from cli_anything.iterm2_ctl.core.prompt import _prompt_to_dict + result = _prompt_to_dict(None) + self.assertFalse(result["available"]) + + def test_prompt_to_dict_with_mock(self): + """_prompt_to_dict converts a mock prompt object to dict.""" + from cli_anything.iterm2_ctl.core.prompt import _prompt_to_dict + mock_prompt = MagicMock() + mock_prompt.unique_id = "uid-1" + mock_prompt.command = "ls -la" + mock_prompt.working_directory = "/home/user" + mock_prompt.state = MagicMock() + mock_prompt.state.name = "RUNNING" + mock_prompt.prompt_range = None + mock_prompt.command_range = MagicMock() + mock_prompt.output_range = None + result = _prompt_to_dict(mock_prompt) + self.assertTrue(result["available"]) + self.assertEqual(result["command"], "ls -la") + self.assertEqual(result["working_directory"], "/home/user") + self.assertEqual(result["state"], "RUNNING") + self.assertFalse(result["has_prompt_range"]) + self.assertTrue(result["has_command_range"]) + + def test_get_last_prompt_returns_unavailable_for_none(self): + """get_last_prompt returns available=False when API returns None.""" + import asyncio + from unittest.mock import AsyncMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.prompt import get_last_prompt + + with patch( + "iterm2.async_get_last_prompt", + new=AsyncMock(return_value=None), + ): + result = await get_last_prompt(MagicMock(), "sess-1") + self.assertFalse(result["available"]) + + asyncio.run(_run()) + + def test_list_prompts_empty(self): + """list_prompts returns empty list when no prompts recorded.""" + import asyncio + from unittest.mock import AsyncMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.prompt import list_prompts + + with patch( + "iterm2.async_list_prompts", + new=AsyncMock(return_value=[]), + ): + result = await list_prompts(MagicMock(), "sess-1") + self.assertEqual(result["prompt_ids"], []) + self.assertEqual(result["count"], 0) + self.assertEqual(result["session_id"], "sess-1") + + asyncio.run(_run()) + + +# โ”€โ”€ Tmux bootstrap logic tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestTmuxBootstrap(unittest.TestCase): + """Unit tests for core/tmux.bootstrap().""" + + def test_bootstrap_timeout_raises(self): + """bootstrap raises RuntimeError when no connection appears in time.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.tmux import bootstrap + + mock_session = MagicMock() + mock_session.async_send_text = AsyncMock() + + mock_tab = MagicMock() + mock_tab.current_session = mock_session + + mock_window = MagicMock() + mock_window.current_tab = mock_tab + + mock_app = MagicMock() + mock_app.windows = [mock_window] + + with patch("iterm2.async_get_app", new=AsyncMock(return_value=mock_app)), \ + patch("iterm2.async_get_tmux_connections", new=AsyncMock(return_value=[])): + with self.assertRaises(RuntimeError) as ctx: + await bootstrap(MagicMock(), timeout=0.1) + self.assertIn("Timed out", str(ctx.exception)) + + asyncio.run(_run()) + + def test_bootstrap_no_windows_raises(self): + """bootstrap raises RuntimeError when no windows exist.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.tmux import bootstrap + + mock_app = MagicMock() + mock_app.windows = [] + + with patch("iterm2.async_get_app", new=AsyncMock(return_value=mock_app)), \ + patch("iterm2.async_get_tmux_connections", new=AsyncMock(return_value=[])): + with self.assertRaises(RuntimeError) as ctx: + await bootstrap(MagicMock(), timeout=0.1) + self.assertIn("No iTerm2 windows", str(ctx.exception)) + + asyncio.run(_run()) + + +# โ”€โ”€ CLI help tests for new commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestNewCommandHelp(unittest.TestCase): + """Smoke-test --help for every new command added in the refine pass.""" + + def _help(self, *args): + from click.testing import CliRunner + from cli_anything.iterm2_ctl.iterm2_ctl_cli import cli + runner = CliRunner() + result = runner.invoke(cli, list(args) + ["--help"]) + self.assertEqual(result.exit_code, 0, result.output) + return result.output + + def test_app_alert_help(self): + out = self._help("app", "alert") + self.assertIn("modal alert", out.lower()) + + def test_app_text_input_help(self): + out = self._help("app", "text-input") + self.assertIn("text input", out.lower()) + + def test_app_file_panel_help(self): + out = self._help("app", "file-panel") + self.assertIn("open file panel", out.lower()) + + def test_app_save_panel_help(self): + out = self._help("app", "save-panel") + self.assertIn("save file panel", out.lower()) + + def test_session_inject_help(self): + out = self._help("session", "inject") + self.assertIn("inject", out.lower()) + self.assertIn("hex", out.lower()) + + def test_tab_select_pane_help(self): + out = self._help("tab", "select-pane") + self.assertIn("direction", out.lower()) + + def test_profile_get_help(self): + out = self._help("profile", "get") + self.assertIn("guid", out.lower()) + + def test_pref_list_keys_help(self): + out = self._help("pref", "list-keys") + self.assertIn("preference", out.lower()) + + +# โ”€โ”€ Dialogs core tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestDialogsCore(unittest.TestCase): + def test_show_alert_calls_api(self): + """show_alert constructs an Alert and returns button info.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.dialogs import show_alert + + mock_alert_instance = MagicMock() + mock_alert_instance.async_run = AsyncMock(return_value=1000) + + with patch("iterm2.Alert", return_value=mock_alert_instance): + result = await show_alert(MagicMock(), "Title", "Sub") + self.assertEqual(result["button_index"], 1000) + self.assertEqual(result["button_label"], "OK") + + asyncio.run(_run()) + + def test_show_alert_with_buttons(self): + """show_alert maps button index back to label.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.dialogs import show_alert + + mock_alert_instance = MagicMock() + mock_alert_instance.async_run = AsyncMock(return_value=1001) + + with patch("iterm2.Alert", return_value=mock_alert_instance): + result = await show_alert(MagicMock(), "T", "S", + buttons=["Yes", "No"]) + self.assertEqual(result["button_index"], 1001) + self.assertEqual(result["button_label"], "No") + + asyncio.run(_run()) + + def test_show_text_input_cancelled(self): + """show_text_input returns cancelled=True when result is None.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.dialogs import show_text_input + + mock_alert = MagicMock() + mock_alert.async_run = AsyncMock(return_value=None) + + with patch("iterm2.TextInputAlert", return_value=mock_alert): + result = await show_text_input(MagicMock(), "T", "S") + self.assertTrue(result["cancelled"]) + self.assertIsNone(result["text"]) + + asyncio.run(_run()) + + def test_show_text_input_value(self): + """show_text_input returns entered text.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.dialogs import show_text_input + + mock_alert = MagicMock() + mock_alert.async_run = AsyncMock(return_value="hello") + + with patch("iterm2.TextInputAlert", return_value=mock_alert): + result = await show_text_input(MagicMock(), "T", "S") + self.assertFalse(result["cancelled"]) + self.assertEqual(result["text"], "hello") + + asyncio.run(_run()) + + def test_show_open_panel_cancelled(self): + """show_open_panel returns cancelled=True when panel is dismissed.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.dialogs import show_open_panel + + mock_panel = MagicMock() + mock_panel.async_run = AsyncMock(return_value=None) + + with patch("iterm2.OpenPanel", return_value=mock_panel): + result = await show_open_panel(MagicMock(), "Open") + self.assertTrue(result["cancelled"]) + self.assertEqual(result["files"], []) + + asyncio.run(_run()) + + def test_show_open_panel_files(self): + """show_open_panel returns chosen files.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.dialogs import show_open_panel + + mock_result = MagicMock() + mock_result.files = ["/Users/alex/foo.py"] + + mock_panel = MagicMock() + mock_panel.async_run = AsyncMock(return_value=mock_result) + mock_panel.options = [] + + with patch("iterm2.OpenPanel", return_value=mock_panel): + result = await show_open_panel(MagicMock(), "Open") + self.assertFalse(result["cancelled"]) + self.assertEqual(result["files"], ["/Users/alex/foo.py"]) + + asyncio.run(_run()) + + +# โ”€โ”€ Tab select-pane tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestTabSelectPane(unittest.TestCase): + def test_invalid_direction_raises(self): + """select_pane_in_direction raises ValueError for unknown direction.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.tab import select_pane_in_direction + + with self.assertRaises(ValueError) as ctx: + await select_pane_in_direction(MagicMock(), "t1", "diagonal") + self.assertIn("diagonal", str(ctx.exception)) + + asyncio.run(_run()) + + def test_valid_direction_calls_api(self): + """select_pane_in_direction calls async_select_pane_in_direction.""" + import asyncio + import iterm2 + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.tab import select_pane_in_direction + + mock_tab = MagicMock() + mock_tab.tab_id = "t1" + mock_tab.async_select_pane_in_direction = AsyncMock(return_value="s_new") + + with patch("cli_anything.iterm2_ctl.utils.iterm2_backend.async_find_tab", + new=AsyncMock(return_value=mock_tab)): + result = await select_pane_in_direction(MagicMock(), "t1", "right") + + self.assertEqual(result["new_session_id"], "s_new") + self.assertTrue(result["moved"]) + + asyncio.run(_run()) + + def test_no_pane_in_direction(self): + """Returns moved=False when API returns None.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + async def _run(): + from cli_anything.iterm2_ctl.core.tab import select_pane_in_direction + + mock_tab = MagicMock() + mock_tab.tab_id = "t1" + mock_tab.async_select_pane_in_direction = AsyncMock(return_value=None) + + with patch("cli_anything.iterm2_ctl.utils.iterm2_backend.async_find_tab", + new=AsyncMock(return_value=mock_tab)): + result = await select_pane_in_direction(MagicMock(), "t1", "left") + + self.assertFalse(result["moved"]) + + asyncio.run(_run()) + + +# โ”€โ”€ Session inject tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestSessionInjectCLI(unittest.TestCase): + def test_inject_help(self): + from click.testing import CliRunner + from cli_anything.iterm2_ctl.iterm2_ctl_cli import cli + runner = CliRunner() + result = runner.invoke(cli, ["session", "inject", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("--hex", result.output) + + def test_inject_hex_invalid(self): + """--hex with invalid hex string exits with error.""" + from click.testing import CliRunner + from cli_anything.iterm2_ctl.iterm2_ctl_cli import cli + runner = CliRunner() + result = runner.invoke(cli, ["session", "inject", "ZZZZ", "--hex"]) + self.assertNotEqual(result.exit_code, 0) + + +# โ”€โ”€ pref list-keys tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestPrefListKeys(unittest.TestCase): + def test_list_keys_returns_keys(self): + from click.testing import CliRunner + from cli_anything.iterm2_ctl.iterm2_ctl_cli import cli + runner = CliRunner() + result = runner.invoke(cli, ["pref", "list-keys"]) + self.assertEqual(result.exit_code, 0) + # Should list something + self.assertIn("preference key(s)", result.output) + + def test_list_keys_filter(self): + from click.testing import CliRunner + from cli_anything.iterm2_ctl.iterm2_ctl_cli import cli + runner = CliRunner() + result = runner.invoke(cli, ["pref", "list-keys", "--filter", "TMUX"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("TMUX", result.output) + + +# โ”€โ”€ _get_process_name tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestGetProcessName(unittest.TestCase): + def test_returns_none_for_none_pid(self): + from cli_anything.iterm2_ctl.core.session import _get_process_name + self.assertIsNone(_get_process_name(None)) + + def test_returns_process_name_for_real_pid(self): + """Should return a non-empty string for the current process PID.""" + import os + from cli_anything.iterm2_ctl.core.session import _get_process_name + name = _get_process_name(os.getpid()) + self.assertIsNotNone(name) + self.assertIsInstance(name, str) + self.assertGreater(len(name), 0) + + def test_returns_none_for_invalid_pid(self): + from cli_anything.iterm2_ctl.core.session import _get_process_name + # PID 999999999 almost certainly doesn't exist + result = _get_process_name(999999999) + self.assertIsNone(result) + + def test_strips_path_prefix(self): + """Should return only the basename, not a full path like /usr/bin/python3.""" + from cli_anything.iterm2_ctl.core.session import _get_process_name + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="/usr/bin/python3\n", returncode=0) + result = _get_process_name(12345) + self.assertEqual(result, "python3") + + def test_returns_none_on_subprocess_exception(self): + from cli_anything.iterm2_ctl.core.session import _get_process_name + with patch("subprocess.run", side_effect=OSError("no ps")): + result = _get_process_name(12345) + self.assertIsNone(result) + + def test_handles_string_pid(self): + """PIDs from iTerm2 variables arrive as strings.""" + from cli_anything.iterm2_ctl.core.session import _get_process_name + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="zsh\n", returncode=0) + result = _get_process_name("12345") + self.assertEqual(result, "zsh") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/tests/test_full_e2e.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/tests/test_full_e2e.py new file mode 100644 index 000000000..5bc3da055 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/tests/test_full_e2e.py @@ -0,0 +1,608 @@ +"""E2E tests for cli-anything-iterm2. + +These tests require iTerm2 to be running with the Python API enabled. + +Prerequisites: + 1. iTerm2 is running + 2. iTerm2 โ†’ Preferences โ†’ General โ†’ Magic โ†’ Enable Python API โœ“ + 3. pip install iterm2 cli-anything-iterm2 (or pip install -e .) + +Run with: + python3 -m pytest cli_anything/iterm2_ctl/tests/test_full_e2e.py -v -s + CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest ... -v -s +""" +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +# โ”€โ”€ resolve CLI helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def _resolve_cli(name: str): + """Resolve installed CLI command; falls back to python -m for dev. + + Set env CLI_ANYTHING_FORCE_INSTALLED=1 to require the installed command. + """ + force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1" + path = shutil.which(name) + if path: + print(f"[_resolve_cli] Using installed command: {path}") + return [path] + if force: + raise RuntimeError( + f"{name} not found in PATH. Install with:\n" + f" cd /path/to/iTerm2-master/agent-harness && pip install -e ." + ) + module = "cli_anything.iterm2_ctl.iterm2_ctl_cli" + print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}") + return [sys.executable, "-m", module] + + +# โ”€โ”€ Fixtures โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@pytest.fixture +def iterm2_connection(): + """Provide a live iTerm2 connection. Skips if iTerm2 is not available.""" + try: + import iterm2 + except ImportError: + pytest.skip("iterm2 Python package not installed") + + # Quick connectivity check + try: + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.window import list_windows + run_iterm2(list_windows) + except Exception as e: + pytest.skip(f"iTerm2 not reachable: {e}") + + return True # signal that connection is available + + +# โ”€โ”€ App tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestAppStatus: + def test_app_status(self, iterm2_connection): + """Get app status โ€” should return window count.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + import iterm2 + + async def _get_status(conn): + app = await iterm2.async_get_app(conn) + return {"window_count": len(app.windows)} + + result = run_iterm2(_get_status) + assert "window_count" in result + assert result["window_count"] >= 0 + print(f"\n App status: {result['window_count']} window(s)") + + def test_get_current_context(self, iterm2_connection): + """Get current focused window/tab/session.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.window import get_current_window + + result = run_iterm2(get_current_window) + # May be None if no window focused, but should not raise + print(f"\n Current context: {result}") + if result is not None: + assert "window_id" in result + + +class TestWorkspaceSnapshot: + def test_workspace_snapshot_structure(self, iterm2_connection): + """snapshot returns session_count and sessions list with required keys.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.session import workspace_snapshot + + result = run_iterm2(workspace_snapshot) + assert "session_count" in result + assert "sessions" in result + assert isinstance(result["sessions"], list) + assert result["session_count"] == len(result["sessions"]) + print(f"\n Snapshot: {result['session_count']} session(s)") + for s in result["sessions"]: + assert "session_id" in s + assert "name" in s + assert "window_id" in s + assert "tab_id" in s + assert "path" in s + assert "pid" in s + assert "process" in s + assert "role" in s + assert "last_line" in s + print(f" {s['session_id']} name={s['name']} " + f"process={s['process']} path={s['path']}") + + def test_workspace_snapshot_process_populated(self, iterm2_connection): + """process field should be a non-empty string for sessions with a running shell.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.session import workspace_snapshot + + result = run_iterm2(workspace_snapshot) + if result["session_count"] > 0: + # At least one session should have a process name + processes = [s["process"] for s in result["sessions"] if s["process"]] + assert len(processes) > 0, "Expected at least one session with a process name" + print(f"\n Processes found: {processes}") + + +# โ”€โ”€ Window tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestWindowOperations: + def test_list_windows(self, iterm2_connection): + """List windows returns a list.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.window import list_windows + + result = run_iterm2(list_windows) + assert isinstance(result, list) + print(f"\n Windows: {len(result)}") + for w in result: + print(f" {w['window_id']} tabs={w['tab_count']}") + + def test_create_and_close_window(self, iterm2_connection): + """Create a window, verify it appears in the list, then close it.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.window import ( + list_windows, create_window, close_window + ) + + # Create + created = run_iterm2(create_window) + assert "window_id" in created + wid = created["window_id"] + print(f"\n Created window: {wid}") + + # Verify it appears in list + windows = run_iterm2(list_windows) + ids = [w["window_id"] for w in windows] + assert wid in ids, f"Window {wid} not in list: {ids}" + + # Close + closed = run_iterm2(close_window, wid, force=True) + assert closed["closed"] is True + print(f" Closed window: {wid}") + + # Verify removed + time.sleep(0.3) + windows_after = run_iterm2(list_windows) + ids_after = [w["window_id"] for w in windows_after] + assert wid not in ids_after + + def test_window_frame(self, iterm2_connection): + """Get window frame returns numeric x/y/w/h.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.window import create_window, close_window, get_window_frame + + created = run_iterm2(create_window) + wid = created["window_id"] + try: + frame = run_iterm2(get_window_frame, wid) + assert "x" in frame + assert "y" in frame + assert "width" in frame + assert "height" in frame + assert frame["width"] > 0 + assert frame["height"] > 0 + print(f"\n Frame: x={frame['x']} y={frame['y']} " + f"w={frame['width']} h={frame['height']}") + finally: + run_iterm2(close_window, wid, force=True) + + +# โ”€โ”€ Tab tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestTabOperations: + def test_list_tabs(self, iterm2_connection): + """List tabs returns a list.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.tab import list_tabs + + result = run_iterm2(list_tabs) + assert isinstance(result, list) + print(f"\n Tabs: {len(result)}") + + def test_create_and_close_tab(self, iterm2_connection): + """Create a tab in a new window, verify it, then close.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.window import create_window, close_window + from cli_anything.iterm2_ctl.core.tab import create_tab, list_tabs + + # Create window for test + w = run_iterm2(create_window) + wid = w["window_id"] + try: + # Create extra tab + tab = run_iterm2(create_tab, window_id=wid) + assert "tab_id" in tab + tid = tab["tab_id"] + print(f"\n Created tab: {tid} in window {wid}") + + # Verify it appears + tabs = run_iterm2(list_tabs, window_id=wid) + tab_ids = [t["tab_id"] for t in tabs] + assert tid in tab_ids + finally: + run_iterm2(close_window, wid, force=True) + + +# โ”€โ”€ Session tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestSessionOperations: + def test_list_sessions(self, iterm2_connection): + """List sessions returns a list.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.session import list_sessions + + result = run_iterm2(list_sessions) + assert isinstance(result, list) + assert len(result) >= 1 + print(f"\n Sessions: {len(result)}") + for s in result: + print(f" {s['session_id']} name={s['name']}") + + def test_send_text_and_read_screen(self, iterm2_connection): + """Send a command to a session and verify screen output.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.window import create_window, close_window + from cli_anything.iterm2_ctl.core.session import ( + list_sessions, send_text, get_screen_contents + ) + + w = run_iterm2(create_window) + wid = w["window_id"] + sid = w["session_id"] + try: + # Send a distinctive command + marker = "CLI_TEST_MARKER_12345" + run_iterm2(send_text, sid, f"echo {marker}\n", suppress_broadcast=False) + time.sleep(0.5) # let the shell process it + + # Read screen + screen = run_iterm2(get_screen_contents, sid) + assert "lines" in screen + assert screen["total_lines"] > 0 + all_text = "\n".join(screen["lines"]) + assert marker in all_text, f"Marker not found in screen:\n{all_text[:500]}" + print(f"\n Screen read OK โ€” found marker '{marker}'") + print(f" Artifact: session {sid}, {screen['returned_lines']} lines") + finally: + run_iterm2(close_window, wid, force=True) + + def test_split_pane(self, iterm2_connection): + """Split a pane horizontally and verify new session created.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.window import create_window, close_window + from cli_anything.iterm2_ctl.core.session import split_pane, list_sessions + + w = run_iterm2(create_window) + wid = w["window_id"] + sid = w["session_id"] + try: + result = run_iterm2(split_pane, sid, vertical=False) + assert "new_session_id" in result + new_sid = result["new_session_id"] + assert new_sid != sid + print(f"\n Split pane: original={sid}, new={new_sid}") + + # Verify both sessions exist + sessions = run_iterm2(list_sessions, window_id=wid) + session_ids = [s["session_id"] for s in sessions] + assert sid in session_ids + assert new_sid in session_ids + finally: + run_iterm2(close_window, wid, force=True) + + +# โ”€โ”€ Profile tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestProfileOperations: + def test_list_profiles(self, iterm2_connection): + """List profiles returns โ‰ฅ 1 profile.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.profile import list_profiles + + result = run_iterm2(list_profiles) + assert isinstance(result, list) + assert len(result) >= 1 + print(f"\n Profiles: {len(result)}") + for p in result: + print(f" {p['name']}") + + def test_list_color_presets(self, iterm2_connection): + """List color presets returns a list.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.profile import list_color_presets + + result = run_iterm2(list_color_presets) + assert isinstance(result, list) + print(f"\n Color presets: {len(result)}") + if result: + print(f" Sample: {result[:3]}") + + +# โ”€โ”€ Arrangement tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestArrangementOperations: + def test_arrangement_save_list_restore(self, iterm2_connection): + """Save, list, and restore an arrangement.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.arrangement import ( + save_arrangement, list_arrangements, restore_arrangement + ) + + name = "cli-test-arrangement-tmp" + try: + # Save current state + saved = run_iterm2(save_arrangement, name) + assert saved["saved"] is True + print(f"\n Saved arrangement: '{name}'") + + # Verify it appears in list + arrangements = run_iterm2(list_arrangements) + assert name in arrangements + print(f" Found in list: {arrangements}") + + # Restore it + restored = run_iterm2(restore_arrangement, name) + assert restored["restored"] is True + print(f" Restored arrangement: '{name}'") + finally: + # No cleanup API for arrangements โ€” leave it (small footprint) + pass + + +# โ”€โ”€ Tmux tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@pytest.fixture +def tmux_connection(iterm2_connection): + """Skip tests if no active tmux -CC connection is available.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.tmux import list_connections + + connections = run_iterm2(list_connections) + if not connections: + pytest.skip( + "No active tmux -CC connections. " + "Start one with: tmux -CC (or tmux -CC attach)" + ) + return connections + + +class TestTmuxOperations: + def test_list_connections_always_works(self, iterm2_connection): + """list_connections returns a list (possibly empty) without error.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.tmux import list_connections + + result = run_iterm2(list_connections) + assert isinstance(result, list) + print(f"\n Tmux connections: {len(result)}") + for c in result: + print(f" {c['connection_id']} gateway={c['owning_session_id']}") + + def test_list_tmux_tabs_always_works(self, iterm2_connection): + """list_tmux_tabs returns a list (possibly empty) without error.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.tmux import list_tmux_tabs + + result = run_iterm2(list_tmux_tabs) + assert isinstance(result, list) + print(f"\n Tmux-backed tabs: {len(result)}") + for t in result: + print(f" tab={t['tab_id']} tmux-window={t['tmux_window_id']} " + f"connection={t['tmux_connection_id']}") + + def test_send_command_list_sessions(self, tmux_connection): + """Send 'list-sessions' to an active tmux connection.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.tmux import send_command + + conn_id = tmux_connection[0]["connection_id"] + result = run_iterm2(send_command, "list-sessions", connection_id=conn_id) + assert "connection_id" in result + assert "command" in result + assert "output" in result + assert result["command"] == "list-sessions" + # Output must be non-empty (at least one session is active since we're in it) + assert len(result["output"]) > 0 + print(f"\n tmux list-sessions output:\n{result['output']}") + + def test_send_command_list_windows(self, tmux_connection): + """Send 'list-windows' โ€” verifies arbitrary tmux command dispatch.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.tmux import send_command + + result = run_iterm2(send_command, "list-windows") + assert result["output"] + print(f"\n tmux list-windows:\n{result['output']}") + + def test_send_command_display_message(self, tmux_connection): + """Send 'display-message' to get tmux server info.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.tmux import send_command + + result = run_iterm2(send_command, "display-message -p '#{session_name}'") + assert "output" in result + print(f"\n tmux session name: {result['output'].strip()!r}") + + def test_create_and_verify_tmux_window(self, tmux_connection): + """Create a tmux window and verify it appears as an iTerm2 tab.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.tmux import create_window, list_tmux_tabs + from cli_anything.iterm2_ctl.core.window import close_window + + result = run_iterm2(create_window) + assert "window_id" in result + assert "tab_id" in result + assert result["tab_id"] is not None + print(f"\n Created tmux window: window={result['window_id']} " + f"tab={result['tab_id']} session={result['session_id']}") + + # Verify the new tab appears in the tmux-tabs list + time.sleep(0.3) + tabs = run_iterm2(list_tmux_tabs) + tab_ids = [t["tab_id"] for t in tabs] + assert result["tab_id"] in tab_ids, ( + f"New tab {result['tab_id']} not in tmux tabs: {tab_ids}" + ) + print(f" Confirmed new tab {result['tab_id']} in tmux tabs list") + + # Clean up + run_iterm2(close_window, result["window_id"], force=True) + + def test_set_window_visible_roundtrip(self, tmux_connection): + """Hide then show a tmux window and verify no errors.""" + from cli_anything.iterm2_ctl.utils.iterm2_backend import run_iterm2 + from cli_anything.iterm2_ctl.core.tmux import ( + create_window, list_tmux_tabs, set_window_visible + ) + from cli_anything.iterm2_ctl.core.window import close_window + + # Create a tmux window to play with + created = run_iterm2(create_window) + wid = created["window_id"] + tid = created["tab_id"] + + try: + time.sleep(0.3) + # Find its tmux_window_id + tabs = run_iterm2(list_tmux_tabs) + tmux_wid = next( + (t["tmux_window_id"] for t in tabs if t["tab_id"] == tid), None + ) + if tmux_wid is None: + pytest.skip("Could not find tmux_window_id for new tab โ€” may vary by iTerm2 version") + + # Hide + r_hide = run_iterm2(set_window_visible, tmux_wid, False) + assert r_hide["visible"] is False + print(f"\n Hidden tmux window {tmux_wid}") + + # Show + r_show = run_iterm2(set_window_visible, tmux_wid, True) + assert r_show["visible"] is True + print(f" Shown tmux window {tmux_wid}") + finally: + # Hiding a tmux window removes the corresponding iTerm2 window, + # so close_window may raise ValueError if the window is already gone. + try: + run_iterm2(close_window, wid, force=True) + except (ValueError, RuntimeError): + pass # Already removed โ€” that's fine + + +# โ”€โ”€ CLI subprocess tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestCLISubprocess: + CLI_BASE = _resolve_cli("cli-anything-iterm2") + + def _run(self, args, check=True, timeout=15): + return subprocess.run( + self.CLI_BASE + args, + capture_output=True, + text=True, + check=check, + timeout=timeout, + ) + + def test_help(self): + """--help exits 0 and mentions commands.""" + result = self._run(["--help"]) + assert result.returncode == 0 + assert "iterm2" in result.stdout.lower() or "window" in result.stdout.lower() + print(f"\n help output: {result.stdout[:200]}") + + def test_json_app_status(self): + """--json app status returns parseable JSON.""" + try: + result = self._run(["--json", "app", "status"]) + data = json.loads(result.stdout) + assert "window_count" in data + print(f"\n JSON app status: {data}") + except (subprocess.CalledProcessError, json.JSONDecodeError) as e: + pytest.skip(f"iTerm2 not available for subprocess test: {e}") + + def test_json_window_list(self): + """--json window list returns parseable JSON list.""" + try: + result = self._run(["--json", "window", "list"]) + data = json.loads(result.stdout) + assert "windows" in data + assert isinstance(data["windows"], list) + print(f"\n JSON window list: {len(data['windows'])} window(s)") + except (subprocess.CalledProcessError, json.JSONDecodeError) as e: + pytest.skip(f"iTerm2 not available for subprocess test: {e}") + + def test_json_session_list(self): + """--json session list returns parseable JSON.""" + try: + result = self._run(["--json", "session", "list"]) + data = json.loads(result.stdout) + assert "sessions" in data + assert isinstance(data["sessions"], list) + print(f"\n JSON session list: {len(data['sessions'])} session(s)") + except (subprocess.CalledProcessError, json.JSONDecodeError) as e: + pytest.skip(f"iTerm2 not available for subprocess test: {e}") + + def test_json_profile_list(self): + """--json profile list returns parseable JSON.""" + try: + result = self._run(["--json", "profile", "list"]) + data = json.loads(result.stdout) + assert "profiles" in data + print(f"\n JSON profiles: {len(data['profiles'])} profile(s)") + except (subprocess.CalledProcessError, json.JSONDecodeError) as e: + pytest.skip(f"iTerm2 not available for subprocess test: {e}") + + def test_json_tmux_list(self): + """--json tmux list returns parseable JSON with a 'connections' key.""" + try: + result = self._run(["--json", "tmux", "list"]) + data = json.loads(result.stdout) + assert "connections" in data + assert isinstance(data["connections"], list) + print(f"\n JSON tmux connections: {len(data['connections'])}") + except (subprocess.CalledProcessError, json.JSONDecodeError) as e: + pytest.skip(f"iTerm2 not available for subprocess test: {e}") + + def test_json_tmux_tabs(self): + """--json tmux tabs returns parseable JSON with a 'tmux_tabs' key.""" + try: + result = self._run(["--json", "tmux", "tabs"]) + data = json.loads(result.stdout) + assert "tmux_tabs" in data + assert isinstance(data["tmux_tabs"], list) + print(f"\n JSON tmux tabs: {len(data['tmux_tabs'])}") + except (subprocess.CalledProcessError, json.JSONDecodeError) as e: + pytest.skip(f"iTerm2 not available for subprocess test: {e}") + + def test_tmux_send_no_connections_error(self): + """tmux send without any active connection exits non-zero with clear error.""" + # This test verifies the error path when no tmux -CC is running. + # If tmux IS connected, the command succeeds โ€” which is also fine. + result = self._run(["tmux", "send", "list-sessions"], check=False) + # Either success (tmux connected) or a clear error (not connected) + if result.returncode != 0: + assert "tmux" in result.stderr.lower() or "connection" in result.stderr.lower() + print(f"\n Expected error when no tmux: {result.stderr.strip()[:120]}") + else: + print(f"\n tmux connected โ€” command succeeded: {result.stdout[:80]}") + + def test_tmux_help(self): + """tmux --help exits 0 and mentions subcommands.""" + result = self._run(["tmux", "--help"]) + assert result.returncode == 0 + assert "send" in result.stdout + assert "list" in result.stdout + + +if __name__ == "__main__": + import pytest + pytest.main([__file__, "-v", "-s"]) diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/utils/__init__.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/utils/iterm2_backend.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/utils/iterm2_backend.py new file mode 100644 index 000000000..9985deed4 --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/utils/iterm2_backend.py @@ -0,0 +1,142 @@ +"""iTerm2 backend โ€” wraps the iterm2 Python API. + +This module provides the bridge between the synchronous Click CLI and +the async iterm2 Python API. All iTerm2 operations are async; this module +wraps them for use in synchronous Click commands. + +iTerm2 must be running for any operation to work. Enable the Python API +at iTerm2 โ†’ Preferences โ†’ General โ†’ Magic โ†’ Enable Python API. +""" +import asyncio +import os +import sys +from typing import Any, Callable, Coroutine + + +def find_iterm2_app() -> str: + """Return the path to iTerm2.app, or raise RuntimeError with install instructions.""" + candidates = [ + "/Applications/iTerm.app", + os.path.expanduser("~/Applications/iTerm.app"), + ] + for path in candidates: + if os.path.isdir(path): + return path + raise RuntimeError( + "iTerm2 is not installed or not in the expected location.\n" + "Install it from: https://iterm2.com/\n" + "Then ensure it is running before using this CLI.\n" + "Also enable the Python API:\n" + " iTerm2 โ†’ Preferences โ†’ General โ†’ Magic โ†’ Enable Python API" + ) + + +def require_iterm2_running(): + """Raise a clear error if iTerm2 is not reachable.""" + try: + import iterm2 # noqa: F401 + except ImportError: + raise RuntimeError( + "The 'iterm2' Python package is not installed.\n" + "Install it with: pip install iterm2" + ) + + +def run_iterm2(coro_fn: Callable, *args, **kwargs) -> Any: + """Run an async iTerm2 operation synchronously. + + Args: + coro_fn: async function with signature (connection, *args, **kwargs) -> result + *args: positional args to pass to coro_fn + **kwargs: keyword args to pass to coro_fn + + Returns: + The return value of coro_fn. + + Raises: + RuntimeError: If iTerm2 is not running or the API is not enabled. + ConnectionRefusedError: If the WebSocket connection fails. + """ + require_iterm2_running() + import iterm2 + + result_holder: dict = {} + error_holder: dict = {} + + async def _wrapper(connection): + try: + result_holder["value"] = await coro_fn(connection, *args, **kwargs) + except Exception as exc: + error_holder["exc"] = exc + + try: + iterm2.run_until_complete(_wrapper) + except Exception as exc: + # run_until_complete raises on connection failure + err_msg = str(exc) + if "connect" in err_msg.lower() or "refused" in err_msg.lower() or "websocket" in err_msg.lower(): + raise RuntimeError( + "Cannot connect to iTerm2.\n" + "Make sure:\n" + " 1. iTerm2 is running\n" + " 2. Python API is enabled: iTerm2 โ†’ Preferences โ†’ General โ†’ Magic โ†’ Enable Python API\n" + f" (Original error: {exc})" + ) from exc + raise + + if "exc" in error_holder: + raise error_holder["exc"] + + return result_holder.get("value") + + +async def async_get_app(connection): + """Get the iTerm2 App singleton.""" + import iterm2 + return await iterm2.async_get_app(connection) + + +async def async_find_window(connection, window_id: str): + """Find a window by ID or raise ValueError.""" + import iterm2 + app = await iterm2.async_get_app(connection) + for window in app.windows: + if window.window_id == window_id: + return window + available = [w.window_id for w in app.windows] + raise ValueError(f"Window '{window_id}' not found. Available: {available}") + + +async def async_find_tab(connection, tab_id: str): + """Find a tab by ID across all windows, or raise ValueError.""" + import iterm2 + app = await iterm2.async_get_app(connection) + for window in app.windows: + for tab in window.tabs: + if tab.tab_id == tab_id: + return tab + raise ValueError(f"Tab '{tab_id}' not found.") + + +async def async_find_session(connection, session_id: str): + """Find a session by ID across all windows/tabs, or raise ValueError.""" + import iterm2 + app = await iterm2.async_get_app(connection) + for window in app.windows: + for tab in window.tabs: + for session in tab.sessions: + if session.session_id == session_id: + return session + raise ValueError(f"Session '{session_id}' not found.") + + +def connection_error_help() -> str: + """Return helpful text for connection errors.""" + return ( + "iTerm2 connection failed.\n" + "Steps to fix:\n" + " 1. Open iTerm2\n" + " 2. Go to: Preferences โ†’ General โ†’ Magic\n" + " 3. Check 'Enable Python API'\n" + " 4. Restart iTerm2 if you just enabled it" + ) diff --git a/iterm2/agent-harness/cli_anything/iterm2_ctl/utils/repl_skin.py b/iterm2/agent-harness/cli_anything/iterm2_ctl/utils/repl_skin.py new file mode 100644 index 000000000..c7312348a --- /dev/null +++ b/iterm2/agent-harness/cli_anything/iterm2_ctl/utils/repl_skin.py @@ -0,0 +1,521 @@ +"""cli-anything REPL Skin โ€” Unified terminal interface for all CLI harnesses. + +Copy this file into your CLI package at: + cli_anything/<software>/utils/repl_skin.py + +Usage: + from cli_anything.<software>.utils.repl_skin import ReplSkin + + skin = ReplSkin("shotcut", version="1.0.0") + skin.print_banner() # auto-detects skills/SKILL.md inside the package + prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) + skin.success("Project saved") + skin.error("File not found") + skin.warning("Unsaved changes") + skin.info("Processing 24 clips...") + skin.status("Track 1", "3 clips, 00:02:30") + skin.table(headers, rows) + skin.print_goodbye() +""" + +import os +import sys + +# โ”€โ”€ ANSI color codes (no external deps for core styling) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" +_ITALIC = "\033[3m" +_UNDERLINE = "\033[4m" + +# Brand colors +_CYAN = "\033[38;5;80m" # cli-anything brand cyan +_CYAN_BG = "\033[48;5;80m" +_WHITE = "\033[97m" +_GRAY = "\033[38;5;245m" +_DARK_GRAY = "\033[38;5;240m" +_LIGHT_GRAY = "\033[38;5;250m" + +# Software accent colors โ€” each software gets a unique accent +_ACCENT_COLORS = { + "gimp": "\033[38;5;214m", # warm orange + "blender": "\033[38;5;208m", # deep orange + "inkscape": "\033[38;5;39m", # bright blue + "audacity": "\033[38;5;33m", # navy blue + "libreoffice": "\033[38;5;40m", # green + "obs_studio": "\033[38;5;55m", # purple + "kdenlive": "\033[38;5;69m", # slate blue + "shotcut": "\033[38;5;35m", # teal green +} +_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue + +# Status colors +_GREEN = "\033[38;5;78m" +_YELLOW = "\033[38;5;220m" +_RED = "\033[38;5;196m" +_BLUE = "\033[38;5;75m" +_MAGENTA = "\033[38;5;176m" + +# โ”€โ”€ Brand icon โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +# The cli-anything icon: a small colored diamond/chevron mark +_ICON = f"{_CYAN}{_BOLD}โ—†{_RESET}" +_ICON_SMALL = f"{_CYAN}โ–ธ{_RESET}" + +# โ”€โ”€ Box drawing characters โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +_H_LINE = "โ”€" +_V_LINE = "โ”‚" +_TL = "โ•ญ" +_TR = "โ•ฎ" +_BL = "โ•ฐ" +_BR = "โ•ฏ" +_T_DOWN = "โ”ฌ" +_T_UP = "โ”ด" +_T_RIGHT = "โ”œ" +_T_LEFT = "โ”ค" +_CROSS = "โ”ผ" + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape codes for length calculation.""" + import re + return re.sub(r"\033\[[^m]*m", "", text) + + +def _visible_len(text: str) -> int: + """Get visible length of text (excluding ANSI codes).""" + return len(_strip_ansi(text)) + + +class ReplSkin: + """Unified REPL skin for cli-anything CLIs. + + Provides consistent branding, prompts, and message formatting + across all CLI harnesses built with the cli-anything methodology. + """ + + def __init__(self, software: str, version: str = "1.0.0", + history_file: str | None = None, skill_path: str | None = None): + """Initialize the REPL skin. + + Args: + software: Software name (e.g., "gimp", "shotcut", "blender"). + version: CLI version string. + history_file: Path for persistent command history. + Defaults to ~/.cli-anything-<software>/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the package's skills/ directory if not provided. + Displayed in banner for AI agents to know where to read skill info. + """ + self.software = software.lower().replace("-", "_") + self.display_name = software.replace("_", " ").title() + self.version = version + + # Auto-detect skill path from package layout: + # cli_anything/<software>/utils/repl_skin.py (this file) + # cli_anything/<software>/skills/SKILL.md (target) + if skill_path is None: + from pathlib import Path + _auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + if _auto.is_file(): + skill_path = str(_auto) + self.skill_path = skill_path + self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) + + # History file + if history_file is None: + from pathlib import Path + hist_dir = Path.home() / f".cli-anything-{self.software}" + hist_dir.mkdir(parents=True, exist_ok=True) + self.history_file = str(hist_dir / "history") + else: + self.history_file = history_file + + # Detect terminal capabilities + self._color = self._detect_color_support() + + def _detect_color_support(self) -> bool: + """Check if terminal supports color.""" + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("CLI_ANYTHING_NO_COLOR"): + return False + if not hasattr(sys.stdout, "isatty"): + return False + return sys.stdout.isatty() + + def _c(self, code: str, text: str) -> str: + """Apply color code if colors are supported.""" + if not self._color: + return text + return f"{code}{text}{_RESET}" + + # โ”€โ”€ Banner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def print_banner(self): + """Print the startup banner with branding.""" + inner = 54 + + def _box_line(content: str) -> str: + """Wrap content in box drawing, padding to inner width.""" + pad = inner - _visible_len(content) + vl = self._c(_DARK_GRAY, _V_LINE) + return f"{vl}{content}{' ' * max(0, pad)}{vl}" + + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") + bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") + + # Title: โ—† cli-anything ยท Shotcut + icon = self._c(_CYAN + _BOLD, "โ—†") + brand = self._c(_CYAN + _BOLD, "cli-anything") + dot = self._c(_DARK_GRAY, "ยท") + name = self._c(self.accent + _BOLD, self.display_name) + title = f" {icon} {brand} {dot} {name}" + + ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}" + tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" + empty = "" + + # Skill path for agent discovery + skill_line = None + if self.skill_path: + skill_icon = self._c(_MAGENTA, "โ—‡") + skill_label = self._c(_DARK_GRAY, " Skill:") + skill_path_display = self._c(_LIGHT_GRAY, self.skill_path) + skill_line = f" {skill_icon} {skill_label} {skill_path_display}" + + print(top) + print(_box_line(title)) + print(_box_line(ver)) + if skill_line: + print(_box_line(skill_line)) + print(_box_line(empty)) + print(_box_line(tip)) + print(bot) + print() + + # โ”€โ”€ Prompt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def prompt(self, project_name: str = "", modified: bool = False, + context: str = "") -> str: + """Build a styled prompt string for prompt_toolkit or input(). + + Args: + project_name: Current project name (empty if none open). + modified: Whether the project has unsaved changes. + context: Optional extra context to show in prompt. + + Returns: + Formatted prompt string. + """ + parts = [] + + # Icon + if self._color: + parts.append(f"{_CYAN}โ—†{_RESET} ") + else: + parts.append("> ") + + # Software name + parts.append(self._c(self.accent + _BOLD, self.software)) + + # Project context + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + parts.append(f" {self._c(_DARK_GRAY, '[')}") + parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}")) + parts.append(self._c(_DARK_GRAY, ']')) + + parts.append(self._c(_GRAY, " โฏ ")) + + return "".join(parts) + + def prompt_tokens(self, project_name: str = "", modified: bool = False, + context: str = ""): + """Build prompt_toolkit formatted text tokens for the prompt. + + Use with prompt_toolkit's FormattedText for proper ANSI handling. + + Returns: + list of (style, text) tuples for prompt_toolkit. + """ + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + tokens = [] + + tokens.append(("class:icon", "โ—† ")) + tokens.append(("class:software", self.software)) + + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + tokens.append(("class:bracket", " [")) + tokens.append(("class:context", f"{ctx}{mod}")) + tokens.append(("class:bracket", "]")) + + tokens.append(("class:arrow", " โฏ ")) + + return tokens + + def get_prompt_style(self): + """Get a prompt_toolkit Style object matching the skin. + + Returns: + prompt_toolkit.styles.Style + """ + try: + from prompt_toolkit.styles import Style + except ImportError: + return None + + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + + return Style.from_dict({ + "icon": "#5fdfdf bold", # cyan brand color + "software": f"{accent_hex} bold", + "bracket": "#585858", + "context": "#bcbcbc", + "arrow": "#808080", + # Completion menu + "completion-menu.completion": "bg:#303030 #bcbcbc", + "completion-menu.completion.current": f"bg:{accent_hex} #000000", + "completion-menu.meta.completion": "bg:#303030 #808080", + "completion-menu.meta.completion.current": f"bg:{accent_hex} #000000", + # Auto-suggest + "auto-suggest": "#585858", + # Bottom toolbar + "bottom-toolbar": "bg:#1c1c1c #808080", + "bottom-toolbar.text": "#808080", + }) + + # โ”€โ”€ Messages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def success(self, message: str): + """Print a success message with green checkmark.""" + icon = self._c(_GREEN + _BOLD, "โœ“") + print(f" {icon} {self._c(_GREEN, message)}") + + def error(self, message: str): + """Print an error message with red cross.""" + icon = self._c(_RED + _BOLD, "โœ—") + print(f" {icon} {self._c(_RED, message)}", file=sys.stderr) + + def warning(self, message: str): + """Print a warning message with yellow triangle.""" + icon = self._c(_YELLOW + _BOLD, "โš ") + print(f" {icon} {self._c(_YELLOW, message)}") + + def info(self, message: str): + """Print an info message with blue dot.""" + icon = self._c(_BLUE, "โ—") + print(f" {icon} {self._c(_LIGHT_GRAY, message)}") + + def hint(self, message: str): + """Print a subtle hint message.""" + print(f" {self._c(_DARK_GRAY, message)}") + + def section(self, title: str): + """Print a section header.""" + print() + print(f" {self._c(self.accent + _BOLD, title)}") + print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}") + + # โ”€โ”€ Status display โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def status(self, label: str, value: str): + """Print a key-value status line.""" + lbl = self._c(_GRAY, f" {label}:") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def status_block(self, items: dict[str, str], title: str = ""): + """Print a block of status key-value pairs. + + Args: + items: Dict of label -> value pairs. + title: Optional title for the block. + """ + if title: + self.section(title) + + max_key = max(len(k) for k in items) if items else 0 + for label, value in items.items(): + lbl = self._c(_GRAY, f" {label:<{max_key}}") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def progress(self, current: int, total: int, label: str = ""): + """Print a simple progress indicator. + + Args: + current: Current step number. + total: Total number of steps. + label: Optional label for the progress. + """ + pct = int(current / total * 100) if total > 0 else 0 + bar_width = 20 + filled = int(bar_width * current / total) if total > 0 else 0 + bar = "โ–ˆ" * filled + "โ–‘" * (bar_width - filled) + text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}" + if label: + text += f" {self._c(_LIGHT_GRAY, label)}" + print(text) + + # โ”€โ”€ Table display โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def table(self, headers: list[str], rows: list[list[str]], + max_col_width: int = 40): + """Print a formatted table with box-drawing characters. + + Args: + headers: Column header strings. + rows: List of rows, each a list of cell strings. + max_col_width: Maximum column width before truncation. + """ + if not headers: + return + + # Calculate column widths + col_widths = [min(len(h), max_col_width) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = min( + max(col_widths[i], len(str(cell))), max_col_width + ) + + def pad(text: str, width: int) -> str: + t = str(text)[:width] + return t + " " * (width - len(t)) + + # Header + header_cells = [ + self._c(_CYAN + _BOLD, pad(h, col_widths[i])) + for i, h in enumerate(headers) + ] + sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + header_line = f" {sep.join(header_cells)}" + print(header_line) + + # Separator + sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths] + sep_line = self._c(_DARK_GRAY, f" {'โ”€โ”€โ”€'.join([_H_LINE * w for w in col_widths])}") + print(sep_line) + + # Rows + for row in rows: + cells = [] + for i, cell in enumerate(row): + if i < len(col_widths): + cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i]))) + row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + print(f" {row_sep.join(cells)}") + + # โ”€โ”€ Help display โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def help(self, commands: dict[str, str]): + """Print a formatted help listing. + + Args: + commands: Dict of command -> description pairs. + """ + self.section("Commands") + max_cmd = max(len(c) for c in commands) if commands else 0 + for cmd, desc in commands.items(): + cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}") + desc_styled = self._c(_GRAY, f" {desc}") + print(f"{cmd_styled}{desc_styled}") + print() + + # โ”€โ”€ Goodbye โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def print_goodbye(self): + """Print a styled goodbye message.""" + print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n") + + # โ”€โ”€ Prompt toolkit session factory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def create_prompt_session(self): + """Create a prompt_toolkit PromptSession with skin styling. + + Returns: + A configured PromptSession, or None if prompt_toolkit unavailable. + """ + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.history import FileHistory + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.formatted_text import FormattedText + + style = self.get_prompt_style() + + session = PromptSession( + history=FileHistory(self.history_file), + auto_suggest=AutoSuggestFromHistory(), + style=style, + enable_history_search=True, + ) + return session + except ImportError: + return None + + def get_input(self, pt_session, project_name: str = "", + modified: bool = False, context: str = "") -> str: + """Get input from user using prompt_toolkit or fallback. + + Args: + pt_session: A prompt_toolkit PromptSession (or None). + project_name: Current project name. + modified: Whether project has unsaved changes. + context: Optional context string. + + Returns: + User input string (stripped). + """ + if pt_session is not None: + from prompt_toolkit.formatted_text import FormattedText + tokens = self.prompt_tokens(project_name, modified, context) + return pt_session.prompt(FormattedText(tokens)).strip() + else: + raw_prompt = self.prompt(project_name, modified, context) + return input(raw_prompt).strip() + + # โ”€โ”€ Toolbar builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def bottom_toolbar(self, items: dict[str, str]): + """Create a bottom toolbar callback for prompt_toolkit. + + Args: + items: Dict of label -> value pairs to show in toolbar. + + Returns: + A callable that returns FormattedText for the toolbar. + """ + def toolbar(): + from prompt_toolkit.formatted_text import FormattedText + parts = [] + for i, (k, v) in enumerate(items.items()): + if i > 0: + parts.append(("class:bottom-toolbar.text", " โ”‚ ")) + parts.append(("class:bottom-toolbar.text", f" {k}: ")) + parts.append(("class:bottom-toolbar", v)) + return FormattedText(parts) + return toolbar + + +# โ”€โ”€ ANSI 256-color to hex mapping (for prompt_toolkit styles) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +_ANSI_256_TO_HEX = { + "\033[38;5;33m": "#0087ff", # audacity navy blue + "\033[38;5;35m": "#00af5f", # shotcut teal + "\033[38;5;39m": "#00afff", # inkscape bright blue + "\033[38;5;40m": "#00d700", # libreoffice green + "\033[38;5;55m": "#5f00af", # obs purple + "\033[38;5;69m": "#5f87ff", # kdenlive slate blue + "\033[38;5;75m": "#5fafff", # default sky blue + "\033[38;5;80m": "#5fd7d7", # brand cyan + "\033[38;5;208m": "#ff8700", # blender deep orange + "\033[38;5;214m": "#ffaf00", # gimp warm orange +} diff --git a/iterm2/agent-harness/setup.py b/iterm2/agent-harness/setup.py new file mode 100644 index 000000000..8b72a3c4c --- /dev/null +++ b/iterm2/agent-harness/setup.py @@ -0,0 +1,34 @@ +from setuptools import setup, find_namespace_packages + +setup( + name="cli-anything-iterm2", + version="1.0.0", + description="A stateful CLI harness for iTerm2 โ€” control a running iTerm2 instance programmatically.", + long_description=open("README.md", encoding="utf-8").read(), + long_description_content_type="text/markdown", + author="voidfreud", + python_requires=">=3.10", + packages=find_namespace_packages(include=["cli_anything.*"]), + package_data={ + "cli_anything.iterm2_ctl": ["skills/*.md", "skills/references/*.md"], + }, + install_requires=[ + "click>=8.0.0", + "prompt-toolkit>=3.0.0", + "iterm2>=2.7", + ], + entry_points={ + "console_scripts": [ + "cli-anything-iterm2=cli_anything.iterm2_ctl.iterm2_ctl_cli:main", + ], + }, + classifiers=[ + "Programming Language :: Python :: 3", + "Operating System :: MacOS", + "Topic :: Terminals :: Terminal Emulators/X Terminals", + "Intended Audience :: Developers", + ], + # NOTE: iTerm2.app itself is a hard dependency that cannot be expressed here. + # Install it from https://iterm2.com/ and enable the Python API: + # iTerm2 โ†’ Preferences โ†’ General โ†’ Magic โ†’ Enable Python API +) diff --git a/registry.json b/registry.json index 7d63fefbc..8b49c4008 100644 --- a/registry.json +++ b/registry.json @@ -312,6 +312,20 @@ "category": "design", "contributor": "zhangxilong-43", "contributor_url": "https://github.com/zhangxilong-43" + }, + { + "name": "iterm2", + "display_name": "iTerm2", + "version": "1.0.0", + "description": "Control a running iTerm2 instance โ€” manage windows, tabs, split panes, send text, read output, run tmux -CC, broadcast keystrokes, and configure preferences.", + "requires": "iTerm2.app (macOS only)", + "homepage": "https://iterm2.com", + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=iterm2/agent-harness", + "entry_point": "cli-anything-iterm2", + "skill_md": "iterm2/agent-harness/cli_anything/iterm2_ctl/skills/SKILL.md", + "category": "devops", + "contributor": "voidfreud", + "contributor_url": "https://github.com/voidfreud" } ] }