mirror of
https://fastgit.cc/github.com/HKUDS/CLI-Anything
synced 2026-04-20 21:00:28 +08:00
Merge pull request #130 from voidfreud/feat/add-iterm2-cli
feat: add iTerm2 CLI harness to registry
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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__/
|
||||
|
||||
@@ -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 |
|
||||
|
||||
123
iterm2/agent-harness/ITERM2.md
Normal file
123
iterm2/agent-harness/ITERM2.md
Normal 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
|
||||
370
iterm2/agent-harness/README.md
Normal file
370
iterm2/agent-harness/README.md
Normal 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
|
||||
5
iterm2/agent-harness/cli_anything/iterm2_ctl/__main__.py
Normal file
5
iterm2/agent-harness/cli_anything/iterm2_ctl/__main__.py
Normal 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()
|
||||
@@ -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}
|
||||
165
iterm2/agent-harness/cli_anything/iterm2_ctl/core/broadcast.py
Normal file
165
iterm2/agent-harness/cli_anything/iterm2_ctl/core/broadcast.py
Normal 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,
|
||||
}
|
||||
150
iterm2/agent-harness/cli_anything/iterm2_ctl/core/dialogs.py
Normal file
150
iterm2/agent-harness/cli_anything/iterm2_ctl/core/dialogs.py
Normal 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}
|
||||
156
iterm2/agent-harness/cli_anything/iterm2_ctl/core/menu.py
Normal file
156
iterm2/agent-harness/cli_anything/iterm2_ctl/core/menu.py
Normal 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",
|
||||
},
|
||||
]
|
||||
182
iterm2/agent-harness/cli_anything/iterm2_ctl/core/pref.py
Normal file
182
iterm2/agent-harness/cli_anything/iterm2_ctl/core/pref.py
Normal 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,
|
||||
}
|
||||
80
iterm2/agent-harness/cli_anything/iterm2_ctl/core/profile.py
Normal file
80
iterm2/agent-harness/cli_anything/iterm2_ctl/core/profile.py
Normal 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)
|
||||
211
iterm2/agent-harness/cli_anything/iterm2_ctl/core/prompt.py
Normal file
211
iterm2/agent-harness/cli_anything/iterm2_ctl/core/prompt.py
Normal 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),
|
||||
}
|
||||
375
iterm2/agent-harness/cli_anything/iterm2_ctl/core/session.py
Normal file
375
iterm2/agent-harness/cli_anything/iterm2_ctl/core/session.py
Normal 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}
|
||||
@@ -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)
|
||||
143
iterm2/agent-harness/cli_anything/iterm2_ctl/core/tab.py
Normal file
143
iterm2/agent-harness/cli_anything/iterm2_ctl/core/tab.py
Normal 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,
|
||||
}
|
||||
244
iterm2/agent-harness/cli_anything/iterm2_ctl/core/tmux.py
Normal file
244
iterm2/agent-harness/cli_anything/iterm2_ctl/core/tmux.py
Normal 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,
|
||||
}
|
||||
142
iterm2/agent-harness/cli_anything/iterm2_ctl/core/window.py
Normal file
142
iterm2/agent-harness/cli_anything/iterm2_ctl/core/window.py
Normal 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,
|
||||
}
|
||||
1535
iterm2/agent-harness/cli_anything/iterm2_ctl/iterm2_ctl_cli.py
Normal file
1535
iterm2/agent-harness/cli_anything/iterm2_ctl/iterm2_ctl_cli.py
Normal file
File diff suppressed because it is too large
Load Diff
100
iterm2/agent-harness/cli_anything/iterm2_ctl/skills/SKILL.md
Normal file
100
iterm2/agent-harness/cli_anything/iterm2_ctl/skills/SKILL.md
Normal 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 (~10–30 lines):
|
||||
|
||||
| File | Read when you need... |
|
||||
|------|-----------------------|
|
||||
| `references/session-io.md` | Send text, inject bytes, read screen/scrollback, get selection |
|
||||
| `references/session-control.md` | Split panes, activate/close sessions, resize, rename, session variables |
|
||||
| `references/session-shell-integration.md` | wait-prompt, wait-command-end, get-prompt; reliable send→wait→read pattern |
|
||||
| `references/layout-window-tab.md` | Create/close/resize windows and tabs, navigate split panes |
|
||||
| `references/layout-arrangement.md` | Save and restore window layouts |
|
||||
| `references/app-context.md` | **Snapshot** (orientation), status, context management, app vars, modal dialogs, file panels |
|
||||
| `references/profile-pref.md` | Profiles list/get/presets, preferences read/write, tmux pref shortcuts |
|
||||
| `references/broadcast-menu.md` | Broadcast keystrokes to multiple panes, invoke menu items |
|
||||
| `references/tmux-commands.md` | All tmux CLI commands (bootstrap, send, tabs, create-window, set-visible) |
|
||||
| `references/tmux-guide.md` | Full tmux -CC workflow, pane→session ID mapping |
|
||||
| `references/json-session.md` | `--json` schemas for session, window, tab, screen, scrollback, inject |
|
||||
| `references/json-tmux-app.md` | `--json` schemas for tmux, app dialogs, preferences, errors |
|
||||
|
||||
## REPL Mode
|
||||
|
||||
Run without arguments for an interactive REPL that maintains context between commands:
|
||||
```bash
|
||||
cli-anything-iterm2
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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?
|
||||
```
|
||||
@@ -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."}`
|
||||
@@ -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..."}`
|
||||
@@ -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]
|
||||
```
|
||||
@@ -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]
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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).
|
||||
@@ -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}`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
334
iterm2/agent-harness/cli_anything/iterm2_ctl/tests/TEST.md
Normal file
334
iterm2/agent-harness/cli_anything/iterm2_ctl/tests/TEST.md
Normal 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
|
||||
1085
iterm2/agent-harness/cli_anything/iterm2_ctl/tests/test_core.py
Normal file
1085
iterm2/agent-harness/cli_anything/iterm2_ctl/tests/test_core.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"])
|
||||
@@ -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"
|
||||
)
|
||||
521
iterm2/agent-harness/cli_anything/iterm2_ctl/utils/repl_skin.py
Normal file
521
iterm2/agent-harness/cli_anything/iterm2_ctl/utils/repl_skin.py
Normal 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
|
||||
}
|
||||
34
iterm2/agent-harness/setup.py
Normal file
34
iterm2/agent-harness/setup.py
Normal 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
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user