Merge pull request #130 from voidfreud/feat/add-iterm2-cli

feat: add iTerm2 CLI harness to registry
This commit is contained in:
Xiloong Zhang
2026-03-24 22:42:01 +08:00
committed by GitHub
42 changed files with 7255 additions and 1 deletions

4
.gitignore vendored
View File

@@ -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__/

View File

@@ -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 |

View File

@@ -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

View File

@@ -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] <group> <command> [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 <window-id>
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 <window-id>
```
### Tabs
```bash
cli-anything-iterm2 tab list
cli-anything-iterm2 tab create --window-id <id>
cli-anything-iterm2 tab activate <tab-id>
cli-anything-iterm2 tab close <tab-id>
```
### 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 <id> # list tmux windows
cli-anything-iterm2 tmux send "ls -la" --connection-id <id>
cli-anything-iterm2 tmux create-window --connection-id <id>
cli-anything-iterm2 tmux set-visible <window-id>
```
### 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 <session-id> # 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

View File

@@ -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()

View File

@@ -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}

View File

@@ -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,
}

View File

@@ -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}

View File

@@ -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",
},
]

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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),
}

View File

@@ -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}

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 (~1030 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
```

View File

@@ -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
```

View File

@@ -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?
```

View File

@@ -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."}`

View File

@@ -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..."}`

View File

@@ -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]
```

View File

@@ -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]
```

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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).

View File

@@ -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}`.

View File

@@ -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.

View File

@@ -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
```

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"])

View File

@@ -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"
)

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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"
}
]
}