release v0.1.0
This commit is contained in:
commit
69ed6534d3
270
MANIFEST.json
Normal file
270
MANIFEST.json
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 1,
|
||||||
|
"name": "iterm-workspace-ctl",
|
||||||
|
"summary": "完全掌控 iTerm2:查询窗口/tab/session 结构与内容,并做分屏、焦点、输入、改名、几何操作",
|
||||||
|
"tier": 2,
|
||||||
|
"status": "stable",
|
||||||
|
"version": "2d2f88d",
|
||||||
|
"source": {
|
||||||
|
"path": "skills/iterm-workspace-ctl",
|
||||||
|
"commit": "2d2f88d",
|
||||||
|
"describe": "2d2f88d",
|
||||||
|
"dirty": false,
|
||||||
|
"packed_at": "2026-10-02T01:26:22.852Z"
|
||||||
|
},
|
||||||
|
"platforms": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"entry": {
|
||||||
|
"path": "scripts/iterm-ctl.sh"
|
||||||
|
},
|
||||||
|
"commands": [
|
||||||
|
"info",
|
||||||
|
"tree",
|
||||||
|
"ids",
|
||||||
|
"where",
|
||||||
|
"cur",
|
||||||
|
"focus",
|
||||||
|
"split",
|
||||||
|
"cols",
|
||||||
|
"resize",
|
||||||
|
"fullscreen",
|
||||||
|
"send",
|
||||||
|
"key",
|
||||||
|
"capture",
|
||||||
|
"profiles",
|
||||||
|
"scrollback",
|
||||||
|
"tab-list",
|
||||||
|
"tab-new",
|
||||||
|
"tab-select",
|
||||||
|
"tab-close",
|
||||||
|
"win-list",
|
||||||
|
"win-new",
|
||||||
|
"title",
|
||||||
|
"sleep",
|
||||||
|
"id",
|
||||||
|
"split-id",
|
||||||
|
"focus-id",
|
||||||
|
"send-id",
|
||||||
|
"send-raw-id",
|
||||||
|
"key-id",
|
||||||
|
"capture-id",
|
||||||
|
"wait",
|
||||||
|
"wait-id",
|
||||||
|
"close-id",
|
||||||
|
"wait-idle",
|
||||||
|
"idle-state",
|
||||||
|
"send-when-idle",
|
||||||
|
"batch"
|
||||||
|
],
|
||||||
|
"errors": [
|
||||||
|
"usage",
|
||||||
|
"deps",
|
||||||
|
"notfound",
|
||||||
|
"conflict",
|
||||||
|
"timeout",
|
||||||
|
"internal"
|
||||||
|
],
|
||||||
|
"aliases": [
|
||||||
|
"iterm-ctl"
|
||||||
|
],
|
||||||
|
"admin": [
|
||||||
|
"scripts/install.sh",
|
||||||
|
"scripts/python-library-path.sh",
|
||||||
|
"scripts/send-when-idle.sh"
|
||||||
|
],
|
||||||
|
"depends_on": [],
|
||||||
|
"requires": [],
|
||||||
|
"config": [],
|
||||||
|
"external_imports": [],
|
||||||
|
"contract": {
|
||||||
|
"version": 1,
|
||||||
|
"skill": "iterm-workspace-ctl",
|
||||||
|
"tier": 2,
|
||||||
|
"platforms": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"commands": {
|
||||||
|
"batch": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"capture": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"capture-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"close-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"cols": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"cur": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"focus-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"fullscreen": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"idle-state": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"ids": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"key-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"resize": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"scrollback": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"send": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"send-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"send-raw-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"send-when-idle": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"sleep": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"split": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"split-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"tab-close": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"tab-list": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"tab-new": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"tab-select": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"tree": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"wait": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"wait-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"wait-idle": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"where": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"win-list": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"win-new": {
|
||||||
|
"destructive": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": [
|
||||||
|
"conflict",
|
||||||
|
"deps",
|
||||||
|
"internal",
|
||||||
|
"notfound",
|
||||||
|
"timeout",
|
||||||
|
"usage"
|
||||||
|
],
|
||||||
|
"aliases": [
|
||||||
|
"iterm-ctl"
|
||||||
|
],
|
||||||
|
"adminAliases": []
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "REFERENCE.md",
|
||||||
|
"bytes": 9279,
|
||||||
|
"sha256": "fc7b7070c84bf212"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "SKILL.md",
|
||||||
|
"bytes": 10538,
|
||||||
|
"sha256": "5140c3afea686708"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "VERSION",
|
||||||
|
"bytes": 6,
|
||||||
|
"sha256": "e9dd8507f4bf0c6f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "contract.lock.json",
|
||||||
|
"bytes": 2162,
|
||||||
|
"sha256": "19e63443daba212e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "interface.json",
|
||||||
|
"bytes": 4420,
|
||||||
|
"sha256": "87e83ab7a4650d1c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/install.sh",
|
||||||
|
"bytes": 1737,
|
||||||
|
"sha256": "2d1e78d675c5b269"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/iterm-ctl.py",
|
||||||
|
"bytes": 56068,
|
||||||
|
"sha256": "71cac561639e410d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/iterm-ctl.sh",
|
||||||
|
"bytes": 16060,
|
||||||
|
"sha256": "abcd9259385d4df7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/python-library-path.sh",
|
||||||
|
"bytes": 673,
|
||||||
|
"sha256": "0534fb415026ff6f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/send-when-idle.sh",
|
||||||
|
"bytes": 10131,
|
||||||
|
"sha256": "610e30e710608d19"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"leak_scan": {
|
||||||
|
"errors": 0,
|
||||||
|
"warnings": 0,
|
||||||
|
"findings": []
|
||||||
|
}
|
||||||
|
}
|
||||||
14
README.md
Normal file
14
README.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# iterm-workspace-ctl
|
||||||
|
|
||||||
|
完全掌控 iTerm2:查询窗口/tab/session 结构与内容,并做分屏、焦点、输入、改名、几何操作
|
||||||
|
|
||||||
|
以 Pi package(技能形态)发布。
|
||||||
|
技能本体在 `skills/iterm-workspace-ctl/`,用法见其 `SKILL.md` 与 `REFERENCE.md`。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pi install git:gitea.vhkd.top/geekinney/iterm-workspace-ctl.git@v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
装完 `pi-skill list` 能看到 `iterm-workspace-ctl`,`pi-skill iterm-workspace-ctl check` 会告诉还缺什么。
|
||||||
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "iterm-workspace-ctl",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "完全掌控 iTerm2:查询窗口/tab/session 结构与内容,并做分屏、焦点、输入、改名、几何操作"
|
||||||
|
}
|
||||||
106
skills/iterm-workspace-ctl/REFERENCE.md
Normal file
106
skills/iterm-workspace-ctl/REFERENCE.md
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# iTerm 控制入口维护
|
||||||
|
|
||||||
|
当前实现使用 cookie + websocket Python API,不再使用旧 `launch API script named` 通道。`STATUS.md` 是旧通道的历史记录,不代表当前实现。
|
||||||
|
|
||||||
|
- 常用入口:`bash scripts/iterm-ctl.sh <命令>`;无参数或 `help` 显示帮助,`help <命令>` 显示详情。
|
||||||
|
- 首次初始化:`bash scripts/install.sh run`。它会修改 iTerm API 偏好并安装依赖,仅在需要且用户授权时运行;迁移恢复不需要重新授权。
|
||||||
|
- 依赖路径由 `bash scripts/python-library-path.sh path` 解析,默认 `~/Library/Caches/iterm-workspace-ctl/<Python ABI>-<架构>`,不放共享仓库。
|
||||||
|
- 可用 `ITERM_CTL_PYTHON` 指定解释器、`ITERM_CTL_PY_LIB` 指定私有依赖目录、`XDG_CACHE_HOME` 指定缓存根目录。所有入口复用同一个路径原语。
|
||||||
|
- 缺库时命令入口会安装到该缓存;网络失败应报告,不回退到旧 GUI 通道或把二进制提交到配置仓库。
|
||||||
|
- 常用只读查询:`where`(当前位置)、`tab-list`(带 current±N 相对位置)、`tab-show` / `win-show`(范围详情)、`win-list`、`capture-tab`(屏幕/回滚历史)。它们直接输出可读结果,**不要让调用方写脚本解析**;`tree [--json]` 仅供机器处理。`capture` 会读取屏幕正文,不用于无关的健康检查。不要为了验证恢复而分屏、发命令或关闭用户窗格。
|
||||||
|
- 改本技能后跑 `python3 tests/test_entrypoints.py`(不连真实 iTerm,用假模块);真实只读冒烟:`tree`、`where`、`frame`。
|
||||||
|
- `close`/`close-others`/`win-close` 会终止进程,必须确定用户目标,不用于测试。
|
||||||
|
- 同步只提交技能源码。迁移前的本机库可复用到相同解释器/架构的缓存,不能把 macOS 库复制给 Windows 用。
|
||||||
|
|
||||||
|
## 性能事实(改入口前先读,别把慢路径加回来)
|
||||||
|
|
||||||
|
实测(同一台机,`var` 这种单命令经 `pi-skill` 调用):
|
||||||
|
|
||||||
|
| 环节 | 耗时 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `pi-skill` 包装本身 | 0.12s | node,可忽略 |
|
||||||
|
| 解析 python 库路径 | 0.10s | 已缓存到 `~/Library/Caches/iterm-workspace-ctl/.libpath` |
|
||||||
|
| `osascript` 现取 cookie | **0.8s** | 最大的单项浪费 |
|
||||||
|
| websocket 连接(不带 cookie) | 0.27s | 地板 |
|
||||||
|
| websocket 连接(新 cookie,同一次进程内) | 0.05s | 只有紧跟的那次连接快 |
|
||||||
|
| websocket 连接(复用旧 cookie) | 0.55s | 最慢:iTerm2 走回退路径 |
|
||||||
|
|
||||||
|
由此定下的入口规矩(改 `iterm-ctl.sh` 前必读):
|
||||||
|
|
||||||
|
1. **默认不带 cookie 直接连**;只有连接被拒才 `osascript` 取一个新的并重试一次(此时会在同一进程内立刻用掉,才享受得到 0.05s)。
|
||||||
|
2. 若环境里已经继承了 `ITERM2_COOKIE`(别的脚本 export 的),**先 unset**:它多半已是一次性令牌用过的旧值,留着就是走 0.55s 的慢路径。
|
||||||
|
(历史 bug:旧入口把 cookie 导出成 `ITERM_COOKIE`,而 iterm2 库读的是 `ITERM2_COOKIE` —— 白付 0.8s 还没生效。)
|
||||||
|
3. **调用方要合并调用**:单条命令 0.5s 里 0.3s 是连接。同一件事能用 `batch` 一次连完就一次连完(`X=$(cmd)` 可捕获输出)。
|
||||||
|
4. `batch` 的捕获只取**最后一行**:`tree --json` 是多行 JSON,不能这样捕获(实测会拿到 `T={`);多行数据直接输出再用管道解析。
|
||||||
|
|
||||||
|
### 后台 tab 的窗格尺寸不刷新(应用层坑,写自动化必踩)
|
||||||
|
|
||||||
|
`var <id> columns` / `tree --json` 里某栏的尺寸,对**非活动 tab** 是旧值:关栏/切栏后读到的一直是老宽度。
|
||||||
|
实测同一个窗格先后读到 19 / 58 / 177,而 `focus-id` 把它所在 tab 选中后**立刻**变成正确的 177。
|
||||||
|
所以“读几何 → 据此排版”的流程必须**先 `focus-id` 自己**,否则排版全错(`longtext-panes` 就踩过这个坑)。
|
||||||
|
|
||||||
|
## 等目标空闲再发送(wait-idle / send-when-idle)
|
||||||
|
|
||||||
|
用途:目标 tab/窗格正在跑任何东西(批量命令、pi/codex 之类的 TUI),要「等它手头的活干完,再替我发一句话/发一条命令」。
|
||||||
|
目标是通用的:任何 pane/session、任何前台程序,判定只看两条与程序无关的事实。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi-skill iterm-workspace-ctl idle-state <pane|id> # 一次性看忙闲(job/prompt/screen_hash)
|
||||||
|
pi-skill iterm-workspace-ctl wait-idle <pane|id> [选项] [--send "单行文本"] # 前台阻塞,可在脚本里组合
|
||||||
|
pi-skill iterm-workspace-ctl send-when-idle start <pane|id> "单行文本" [选项] # 后台守候 + 台账
|
||||||
|
pi-skill iterm-workspace-ctl send-when-idle status|logs <id>|stop <id|--all>
|
||||||
|
```
|
||||||
|
|
||||||
|
判定语义(`--when`):
|
||||||
|
|
||||||
|
| 值 | 空闲条件 | 适用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `prompt` | 前台是 shell(iTerm jobName=zsh/bash/fish/...)= 命令跑完、回到提示符 | 批量命令;静默长命令也不会误判 |
|
||||||
|
| `quiet` | 前台是别的程序,可见屏连续 `--quiet` 秒(默认 120)没变 = 停在它自己的输入框 | TUI(pi/codex/vim) |
|
||||||
|
| `auto`(默认) | 两者择先 | 通用 |
|
||||||
|
|
||||||
|
判定成立后还会复核 `--confirm` 秒(默认 30;0=立即)再发;期间屏幕又动就重新等。
|
||||||
|
`--poll`(默认 10s)是采样间隔,`--max`(默认 21600s,0=不限)是守候上限,超时退出码 1。
|
||||||
|
|
||||||
|
事实与边界:
|
||||||
|
|
||||||
|
- 文本必须单行(含换行会在目标里提前提交);长文本用 `--file`(末尾换行去掉,内部换行直接拒绝)。
|
||||||
|
- 目标可以是 pane 方位(`left`/`right`/`tab N`/…)或 session id;pane 方位只在 `start` 那一刻解析,之后守候按锚定到的 session id,不受焦点变化影响。
|
||||||
|
- 静默不输出的长命令会被 quiet 误判成空闲;要安全用 `--when prompt`。发出去的文本等价于在那个窗格键入 + 回车。
|
||||||
|
- 台账:`~/.pi/agent/local/skills/iterm-workspace-ctl/jobs/`。每个任务 `.meta`(参数/pid)、`.text`、`.state`(每轮采样,status 显示的 `as_of` 就是最近一次采样时间,也是它有 liveness 的证据)、`.log`、`.result`(退出码;`stopped` 是 stop 写的)。台账只留最近 30 个任务:更旧的、已结束的会在下次 `start` 时自动清掉(守候中的不删)。
|
||||||
|
- 同一目标同时只允许一个守候任务;已经守候中的目标再 `start` 会直接报错(避免两条消息都发进去)。
|
||||||
|
- `stop` 只杀守候进程树,不碰目标 session。
|
||||||
|
- 单测:`python3 tests/test_entrypoints.py` —— 判定函数/参数解析用纯函数测,脚本 start/status/stop 用假 ctl(`ITERM_CTL_CTL` 指向假入口 + `ITERM_CTL_JOBS_DIR` 指向临时目录)测,不连真实 iTerm。
|
||||||
|
|
||||||
|
## 配方:把一条消息投给另一个并发会话
|
||||||
|
|
||||||
|
1. 用 `pi-skill iterm-workspace-ctl ids` 找目标会话 ID,按标题里的项目或主题辨认。
|
||||||
|
2. 运行 `pi-skill iterm-workspace-ctl send-when-idle start <id> "<文本>"`:它先连续静止 `--quiet 120` 秒,再用 `--confirm 30` 秒复核后发送并回车,不会打断对方当前这轮。
|
||||||
|
3. 文本会成为对方的**用户消息**,必须自足地说明谁发的、发现什么、结论或修法和证据位置(提交号或文件路径),并控制在一屏内。
|
||||||
|
4. 用 `send-when-idle status|logs <id>|stop <id>` 查守候台账或停止守候;`stop` 只杀守候进程,不动目标会话。
|
||||||
|
5. 不要用这条通道发送密码或令牌。
|
||||||
|
|
||||||
|
## 标签/窗口标题为什么显示正在运行的命令,怎么关
|
||||||
|
|
||||||
|
现象:`~ (-zsh)`、`sleep (sleep)`、`cd (sleep)` —— 标题跟着前台命令变,命令结束后恢复。**两层叠加,只关一层还会显示命令**:
|
||||||
|
|
||||||
|
| 层 | 来源 | 关闭方法 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| shell | oh-my-zsh 的 `omz_termsupport_preexec`(`lib/termsupport.zsh`)每条命令前用 OSC 1/2 把标题改成命令,`precmd` 改回目录 | `~/.zshrc` 末尾覆盖 `omz_termsupport_preexec() { title "$ZSH_THEME_TERM_TAB_TITLE_IDLE" "$ZSH_THEME_TERM_TITLE_IDLE" }`(保留目录标题;想彻底固定用 `DISABLE_AUTO_TITLE="true"`。只对新开的 shell 生效) |
|
||||||
|
| iTerm2 | profile 的 Title 元素带 Job 位,渲染成 `名字 (job)` | Settings → Profiles → 该配置 → General → Title 里取消 Job;等价 API 是写 `Title Components` 掩码(见下) |
|
||||||
|
|
||||||
|
iTerm2 3.6.9 掩码实测:bit1=2 是 Job;bit0=1 与 bit11=2048 都渲染会话名(`iterm2` 模块枚举只声明到 bit10=SIZE,`.title_components` 读到未知位会 ValueError,读值要用 `_simple_get`)。写共享 profile 会持久化进 plist,但**已在跑的 session 掩码是建会话时的快照**,要让当前屏幕立刻改(例如本会话标题的 `(pi)` 后缀)得逐个写会话级 profile。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 解释器:iTerm2 自带 "$HOME/Library/Application Support/iTerm2/iterm2env-*/versions/*/bin/python3"(含 iterm2 模块)
|
||||||
|
import asyncio, iterm2
|
||||||
|
async def main():
|
||||||
|
conn = await iterm2.Connection.async_create()
|
||||||
|
for p in await iterm2.Profile.async_get(conn):
|
||||||
|
m = p._simple_get('Title Components') or 0
|
||||||
|
if p.name == 'Dark' and m & 2: # 2 = Job
|
||||||
|
await p._async_simple_set('Title Components', m & ~2)
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
验证:新开 tab 跑 `sleep 7`,标题保持目录不变;`tree` 里 `name=` 不再出现 `(job)`。
|
||||||
122
skills/iterm-workspace-ctl/SKILL.md
Normal file
122
skills/iterm-workspace-ctl/SKILL.md
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
name: iterm-workspace-ctl
|
||||||
|
description: 完全掌控用户的 iTerm2 终端:查询(窗口/tab/窗格/session 的编号、结构、焦点、进程、路径、tty、尺寸、屏幕内容、回滚历史)与操作(分屏/多栏、焦点、发送/按键、tab/窗口开关与移动、改名、窗口几何/全屏)。用户问任何关于终端本身的自然语言问题(“左边那个 tab 是什么”“当前第几个 tab”“第二个窗口里几个 session、布局怎样”“某某在干什么”),或要求编排终端时使用。
|
||||||
|
platforms: darwin
|
||||||
|
---
|
||||||
|
|
||||||
|
# iTerm2 控制
|
||||||
|
|
||||||
|
## 入口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi-skill iterm-workspace-ctl help # 完整用法(权威:命令、参数、旗标)
|
||||||
|
pi-skill iterm-workspace-ctl <命令> [参数...] # 执行;输出与退出码原样透传
|
||||||
|
```
|
||||||
|
|
||||||
|
失败 → 动作:`error=usage` 读 `pi-skill iterm-workspace-ctl help`;`error=deps` 补依赖;`error=auth` 跑本技能的登录/检查命令;
|
||||||
|
`error=notfound` 先 `list`/`status` 重新定位;`error=conflict` 先 `status`;其它非零按输出里的 `hint=` 执行。
|
||||||
|
|
||||||
|
**如果你支持关闭 thinking/reasoning,请先关闭再执行。不要思考布局、不要侦察、不要读源码。把用户的话按下表直接编译成一条 bash 命令执行,完成后一句话汇报。查询类问题同样编译成一条只读命令直接回答(见“查询”表),不要写 python/jq 解析输出,也不要靠推测。**
|
||||||
|
|
||||||
|
入口(路径相对本技能目录):`bash scripts/iterm-ctl.sh <命令> [参数]`
|
||||||
|
|
||||||
|
**写操作硬规则**:会改变终端的命令(`send` / `key` / `close` / `split` / 改名 / `tab-close` 等)目标必须写 **session id** —— `tab-new` 直接返回 id;**从某个 tab 里的 shell 发起时(如 agent-shell)直接用 `$ITERM_SESSION_ID` 当锚点**(它就是这个 tab,不随焦点变);只有不在目标 tab 里时才 `id cur` 取锚点;`cur` / `left` / `N` 是**执行瞬间**才解析的(读 iTerm 的 current tab),不是句柄,只允许在单条命令内“读一下立即用”(如 `batch`)。焦点会被坐在键盘前的人随时切走;用 `cur` 发写操作会打进用户正在输入的框里。
|
||||||
|
**例外(高频直连)**:“只留当前窗格 / 关掉其他窗格”直接 `close-others`(缺省保留焦点窗格、内部 force 不等确认)——你能读到这句话,就说明焦点窗格是本会话所在窗格,不要先 `ids`/`tree`/`id cur` 侦察,更不要写代码解析输出。
|
||||||
|
|
||||||
|
窗格 X:`cur`(当前聚焦) `left` `right` `top` `bottom` `center` `1..N`。嵌套布局时 `top`/`bottom` 后可加列限定:`top center`=中栏上、`bottom right`=右栏下。
|
||||||
|
|
||||||
|
| 用户说 | 执行 |
|
||||||
|
|--------|------|
|
||||||
|
| 左右分屏 / 把 X 左右分 | `split v [X] [--focus]` ⚠️ v=竖线=左右,死记;默认后台不抢焦点 |
|
||||||
|
| 上下分屏 / 把 X 上下分 | `split h [X] [--focus]` |
|
||||||
|
| N 栏 / N 行 | `cols N [--focus]` / `rows N [--focus]` |
|
||||||
|
| 焦点切到 X | `focus X` |
|
||||||
|
| 在 X 执行 Y | `send X "Y"` |
|
||||||
|
| 在 X 输入 Y(不执行) | `send-raw X "Y"` |
|
||||||
|
| 打断 X 的命令 | `key X ctrl-c` |
|
||||||
|
| 给 X 按回车/ESC/方向键 | `key X enter` `esc` `up` `down` `left` `right` |
|
||||||
|
| 关掉 X 窗格 | `close X` |
|
||||||
|
| 只留 X 窗格(缺省=当前) | `close-others [X]` |
|
||||||
|
| 新开/切换/关 tab | `tab-new [--profile P] [--command C] [--focus]` / `tab-select N` / `tab-close [N]` |
|
||||||
|
| tab 独立成窗口 | `tab-detach N` |
|
||||||
|
| 新开/关窗口 | `win-new [--profile P] [--command C]` / `win-close [n\|id]` |
|
||||||
|
| 切窗口 / 把 iTerm 前置 | `win-select <n\|id>` / `app-activate` |
|
||||||
|
| 看 X 的输出 / 看历史 | `capture X [N]` / `capture X N --scrollback` |
|
||||||
|
| 等 X 出现"词" | `wait X "词"` |
|
||||||
|
| 等 X 手里的活干完再跟它说句话 | `send-when-idle start X "话"`(后台守候;`send-when-idle status` / `logs <id>` / `stop <id\|--all>` 管) |
|
||||||
|
| 等 X 空闲再动手(前台阻塞,可组合) | `wait-idle X [--when auto\|prompt\|quiet]`,可加 `--send "话"` 一次做完 |
|
||||||
|
| X 现在忙不忙 | `idle-state X`(job / prompt / 屏幕指纹) |
|
||||||
|
| 看所有 session id | `ids` |
|
||||||
|
| 拿住 X 备用(锚定) | `id X` |
|
||||||
|
| 按 id 操作(与焦点无关) | `send-id <id> "Y"` / `key-id <id> ...` / `capture-id <id> [N]` / `wait-id <id> "词"` / `close-id <id>` / `split-id <id> v\|h` |
|
||||||
|
| 改名 / 标题 | `title <pane\|id> "文字"` / `tab-title N "文字"` / `win-title [n\|id] "文字"` |
|
||||||
|
| 窗口几何 / 全屏 | `frame [n\|id] [x y w h]` / `fullscreen <n\|id> on\|off` |
|
||||||
|
| 回滚缓冲(iTerm2 设置,新开会话生效) | `scrollback list` / `scrollback unlimited` / `scrollback set 1000 [--profile P]` |
|
||||||
|
| 按键扩展 | `key X backspace delete home end pageup pagedown F1..F12 ctrl-a..z ctrl-enter`(`ctrl-enter` 走 kitty 协议 CSI u,用于 copilot CLI 这类需要 Ctrl+Enter 排队提交的 TUI) |
|
||||||
|
| 等它干完再交代下一件事 | `wait-idle` / `send-when-idle`(判定=回 shell 提示符或屏幕连续静止;不要用 `sleep` 猜时间,语义与边界见 REFERENCE.md) |
|
||||||
|
| 多步组合 / 复杂任务 | `batch`(从 stdin 一次连接执行;默认用它) |
|
||||||
|
|
||||||
|
## 查询:任何终端问题 → 一条命令(禁止写代码解析)
|
||||||
|
|
||||||
|
**硬规则:回答任何问题都用下表命令直接读,一条 bash 调用拿到现成可读的输出;不要写 python/jq 解析,不要把一条问题拆成多步侦察。**
|
||||||
|
|
||||||
|
| 用户问 | 执行 |
|
||||||
|
|--------|------|
|
||||||
|
| 我在哪?当前是第几个 tab/窗格? | `where` |
|
||||||
|
| 当前窗口有哪些 tab?左边/右边的 tab 是什么? | `tab-list`(输出带 `current-2 / current-1 / current / current+1` 标记,直接读取) |
|
||||||
|
| 第几个 tab、first/last 在干什么? | `capture-tab first\|last\|N [行数]`;看历史加 `--scrollback` |
|
||||||
|
| 某个 tab 有几个 session、布局怎样、什么进程? | `tab-show first\|last\|N` |
|
||||||
|
| 有几个窗口?某个窗口在哪、什么尺寸、全屏吗? | `win-list` / `win-show first\|last\|N` |
|
||||||
|
| 全部结构(窗口/tab/窗格/进程/路径/尺寸) | `tree`(机器处理才用 `tree --json`) |
|
||||||
|
| 某 session 的 cwd/进程/tty/任意变量 | `tab-show`/`tree` 已含 job/path/tty;或 `var <pane\|id> <名字>` |
|
||||||
|
| 有哪些 profile | `profiles` |
|
||||||
|
| 窗口位置/大小/是否全屏 | `frame [n\|id]` / `fullscreen <n\|id> on\|off`(不传 on/off 只读) |
|
||||||
|
| 给某个 tab 发命令/按键(不用先查 id) | `send-tab first\|last\|N "命令"` / `key-tab first\|last\|N ctrl-c` |
|
||||||
|
|
||||||
|
- 编号(window/tab/pane)会随增删变化:回答时以本次输出为准。
|
||||||
|
- “在干什么”看 `tab-show` 的 job/path,“细节/最新输出”用 `capture-tab`;不要臆测。
|
||||||
|
- 用户说“窗口”= iTerm 顶层 window;一个 window 里有多个 tab。
|
||||||
|
|
||||||
|
**锚定规则(跨调用必遵守)**:`left/right/cur` 是相对**当前聚焦 tab** 实时解析的,用户切 tab/窗口后同一条命令会落到别的窗格。凡是要跨多次 bash 调用或多轮对话连续操作的窗格,先 `id <pane>`(或 `ids`)拿到 session id,之后一律用 `*-id` 命令,与前台焦点完全无关。同一时刻连续动作(如 `split v && send right "pi"`)可以直接用方位。
|
||||||
|
|
||||||
|
**焦点规则(默认不抢)**:`split` / `split-id` / `cols` / `rows` / `tab-new` 一律在后台创建,**保持用户当前焦点不动**;只有用户明确说"切过去 / 打开给我看 / 我要看着它"时才加 `--focus`。发送、按键、读屏、关闭等 `*-id` 操作全程不动焦点。绝不主动 `focus`/`focus-id`,除非用户明确要求。
|
||||||
|
|
||||||
|
**批量规则(默认合并执行)**:多步任务默认写成 `batch` heredoc,**一次连接全部执行完**;只有用户明确说"一步步来 / 我要看着它执行 / 每步分开做"时才拆成单条命令逐步执行。batch 内遇错即停,行首 `#` 为注释。支持纯赋值 `A=$ITERM_SESSION_ID`;`$VAR` 没在 batch 里捕获过时退回环境变量(`$ITERM_SESSION_ID` 可直接用,`w0t1p0:` 前缀各 `*-id` 命令都认)。
|
||||||
|
|
||||||
|
组合:多个动作按语序用 `&&` 串成**一条** bash 调用。"等它跑起来" = `sleep 2`。分屏后新窗格在下/右:`split h center && send top "htop" && send bottom "x"` = 中栏上跑 htop、中栏下跑 x。
|
||||||
|
|
||||||
|
例:
|
||||||
|
- 左右分屏右边开 pi → `split v && send right "pi"`
|
||||||
|
- 三栏各开 pi → `cols 3 && send left "pi" && send center "pi" && send right "pi"`
|
||||||
|
- 打断右边再关掉 → `key right ctrl-c && close right`
|
||||||
|
|
||||||
|
**配方(多步组合,一律 id 锚定、不抢焦点;默认用 batch)**:
|
||||||
|
- **新 tab + 左右分屏 + 左 pi / 右 proxy→codex**:
|
||||||
|
```bash
|
||||||
|
pi-skill iterm-workspace-ctl batch <<'EOF'
|
||||||
|
T=$(tab-new) # 后台新 tab;输出左窗格 id,焦点不变
|
||||||
|
R=$(split-id "$T" v) # 原 session 在左、新窗格在右;输出右窗格 id
|
||||||
|
send-id "$T" "pi"
|
||||||
|
send-id "$R" "proxy"
|
||||||
|
sleep 2
|
||||||
|
send-id "$R" "codex"
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
- **新 tab + 多栏各自起工具**:batch 里 `T=$(tab-new)` → 逐个 `R=$(split-id "$T" v)` 收集 id → 对每个 id `send-id <id> "命令"`。
|
||||||
|
- **等启动完成**:用 `wait-id <id> "词" [秒]` 代替盲目 sleep;读结果 `capture-id <id> [行数]`;结束清理 `close-id <id>`。
|
||||||
|
- **在已有后台 tab 里增减窗格**:直接 batch 里 `split-id <该 tab 内任一 session id> v|h`,不会切用户焦点。
|
||||||
|
|
||||||
|
⚠️ `close`/`close-others` 会杀窗格里的进程,别关到用户自己正在用的窗格。
|
||||||
|
|
||||||
|
**复杂流程(>3 步)机械处理**:先在配方表里找相同组合,命中就直接照抄;没命中就切小段查映射表,每段单独编译,用 `&&` 串成一条。跨调用的每一步必须带 `*-id`。不要规划状态转移,不要推演“执行后窗格变成什么样”。
|
||||||
|
|
||||||
|
**生成命令后、执行前,机械核对(不要思考,只对照):**
|
||||||
|
□ 查询类问题是否用了“查询”表的直接命令(tab-list/tab-show/win-show/capture-tab/where)且没写代码解析?
|
||||||
|
□ 命令里每个词都在映射表里出现过?
|
||||||
|
□ 没有 info/capture/grep/ls 等侦察命令?
|
||||||
|
□ 是一条 bash 调用(多步用 && 串)?
|
||||||
|
□ 跨调用操作是否已用 `id`/`ids` 锚定,并改用 `*-id`?
|
||||||
|
□ 创建类是否默认不抢焦点(没自作主张加 `--focus`)?
|
||||||
|
全部打勾 → 立即执行。任何一项没打勾 → 重查映射表,不要自己想。
|
||||||
|
|
||||||
|
**只有命令报错或缺库时,才读 REFERENCE.md**(安装、排查、AppleScript 退回方案)。
|
||||||
1
skills/iterm-workspace-ctl/VERSION
Normal file
1
skills/iterm-workspace-ctl/VERSION
Normal file
@ -0,0 +1 @@
|
|||||||
|
0.1.0
|
||||||
133
skills/iterm-workspace-ctl/contract.lock.json
Normal file
133
skills/iterm-workspace-ctl/contract.lock.json
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skill": "iterm-workspace-ctl",
|
||||||
|
"tier": 2,
|
||||||
|
"platforms": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"commands": {
|
||||||
|
"batch": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"capture": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"capture-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"close-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"cols": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"cur": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"focus-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"fullscreen": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"idle-state": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"ids": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"key-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"resize": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"scrollback": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"send": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"send-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"send-raw-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"send-when-idle": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"sleep": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"split": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"split-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"tab-close": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"tab-list": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"tab-new": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"tab-select": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"tree": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"wait": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"wait-id": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"wait-idle": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"where": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"win-list": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"win-new": {
|
||||||
|
"destructive": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": [
|
||||||
|
"conflict",
|
||||||
|
"deps",
|
||||||
|
"internal",
|
||||||
|
"notfound",
|
||||||
|
"timeout",
|
||||||
|
"usage"
|
||||||
|
],
|
||||||
|
"aliases": [
|
||||||
|
"iterm-ctl"
|
||||||
|
],
|
||||||
|
"adminAliases": []
|
||||||
|
}
|
||||||
200
skills/iterm-workspace-ctl/interface.json
Normal file
200
skills/iterm-workspace-ctl/interface.json
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
{
|
||||||
|
"summary": "完全掌控 iTerm2:查询窗口/tab/session 结构与内容,并做分屏、焦点、输入、改名、几何操作",
|
||||||
|
"useWhen": "用户问终端本身的结构/内容(哪个 tab、几个 session、布局),或要求编排 iTerm2 时",
|
||||||
|
"tier": 2,
|
||||||
|
"entry": {
|
||||||
|
"path": "scripts/iterm-ctl.sh"
|
||||||
|
},
|
||||||
|
"aliases": [
|
||||||
|
"iterm-ctl"
|
||||||
|
],
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "info",
|
||||||
|
"summary": "总览:窗口/tab/session 结构"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tree",
|
||||||
|
"summary": "树状结构"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ids",
|
||||||
|
"summary": "列出 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "where",
|
||||||
|
"summary": "当前焦点位置"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cur",
|
||||||
|
"summary": "当前 session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "focus",
|
||||||
|
"summary": "切焦点"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "split",
|
||||||
|
"summary": "分屏"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cols",
|
||||||
|
"summary": "多栏布局"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resize",
|
||||||
|
"summary": "调尺寸"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fullscreen",
|
||||||
|
"summary": "全屏/取消"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "send",
|
||||||
|
"summary": "向 session 发送文本"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"summary": "发送按键"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "capture",
|
||||||
|
"summary": "读屏幕内容"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profiles",
|
||||||
|
"summary": "列出 profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scrollback",
|
||||||
|
"summary": "读/改 iTerm2 各 profile 的回滚缓冲(unlimited 或行数)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tab-list",
|
||||||
|
"summary": "列出 tab"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tab-new",
|
||||||
|
"summary": "新建 tab"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tab-select",
|
||||||
|
"summary": "切 tab"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tab-close",
|
||||||
|
"summary": "关 tab"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win-list",
|
||||||
|
"summary": "列出窗口"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win-new",
|
||||||
|
"summary": "新建窗口"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"summary": "改标题"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sleep",
|
||||||
|
"summary": "等待"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"summary": "取窗格 session id(跨调用前先锚定)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "split-id",
|
||||||
|
"summary": "按 session id 分屏"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "focus-id",
|
||||||
|
"summary": "按 session id 聚焦"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "send-id",
|
||||||
|
"summary": "按 session id 发送文本并回车"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "send-raw-id",
|
||||||
|
"summary": "按 session id 发送文本(不回车)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "key-id",
|
||||||
|
"summary": "按 session id 发送按键"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "capture-id",
|
||||||
|
"summary": "按 session id 读屏幕内容"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wait",
|
||||||
|
"summary": "轮询窗格直到出现指定字样"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wait-id",
|
||||||
|
"summary": "按 session id 轮询直到出现指定字样"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "close-id",
|
||||||
|
"summary": "按 session id 关闭窗格"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wait-idle",
|
||||||
|
"summary": "等目标空闲(命令回提示符 / TUI 屏幕静止),可选随后发送单行文本(前台阻塞)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idle-state",
|
||||||
|
"summary": "一次性读目标忙闲:job/prompt/屏幕指纹"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "send-when-idle",
|
||||||
|
"summary": "后台守候:目标空闲后把它发进去并回车;start/status/logs/stop(台账与 liveness 在 status 的 as_of)",
|
||||||
|
"destructive": false,
|
||||||
|
"writes": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "batch",
|
||||||
|
"summary": "从 stdin 一次连接顺序执行多条命令"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lifecycle": [
|
||||||
|
"start",
|
||||||
|
"status",
|
||||||
|
"stop",
|
||||||
|
"logs"
|
||||||
|
],
|
||||||
|
"liveness": "as_of",
|
||||||
|
"errors": [
|
||||||
|
"usage",
|
||||||
|
"deps",
|
||||||
|
"notfound",
|
||||||
|
"conflict",
|
||||||
|
"timeout",
|
||||||
|
"internal"
|
||||||
|
],
|
||||||
|
"helpListsCommands": true,
|
||||||
|
"admin": [
|
||||||
|
{
|
||||||
|
"path": "scripts/install.sh",
|
||||||
|
"summary": "一次性初始化:开 iTerm Python API + 装 iterm2 库(幂等)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/python-library-path.sh",
|
||||||
|
"summary": "打印本机 python 库缓存目录"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/send-when-idle.sh",
|
||||||
|
"summary": "send-when-idle 子命令的后台台账实现(由入口转发;不是独立入口)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"publish": {
|
||||||
|
"package_repo": "https://gitea.vhkd.top/geekinney/iterm-workspace-ctl.git"
|
||||||
|
},
|
||||||
|
"catalog": {
|
||||||
|
"layer": "core",
|
||||||
|
"why": "别的技能建在它上面"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
skills/iterm-workspace-ctl/scripts/install.sh
Executable file
41
skills/iterm-workspace-ctl/scripts/install.sh
Executable file
@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 一次性初始化:开启 iTerm2 Python API + 装 iterm2 库(幂等,可重复执行)
|
||||||
|
#
|
||||||
|
# 新版 iTerm2(3.6+)没有 "General → Scripts → Execute command enabled" 勾选,
|
||||||
|
# 改为:Python API 由偏好键 EnableAPIServer 控制,本脚本直接写入并探测。
|
||||||
|
set -euo pipefail
|
||||||
|
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
case "${1:-help}" in
|
||||||
|
help) echo '用法: install.sh run | help [run];run 开启 iTerm Python API、安装依赖并探测。'; exit 0 ;;
|
||||||
|
run) ;;
|
||||||
|
*) echo '未知命令;使用 help' >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
PYTHON="${ITERM_CTL_PYTHON:-python3}"
|
||||||
|
PY_LIB="$(bash "$SELF_DIR/python-library-path.sh" path)"
|
||||||
|
|
||||||
|
echo "== 1. 开启 Python API(EnableAPIServer)=="
|
||||||
|
defaults write com.googlecode.iterm2 EnableAPIServer -bool true
|
||||||
|
echo " EnableAPIServer = $(defaults read com.googlecode.iterm2 EnableAPIServer)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "== 2. 安装 iterm2 库到本机缓存 =="
|
||||||
|
mkdir -p "$PY_LIB"
|
||||||
|
"$PYTHON" -m pip install --quiet --target "$PY_LIB" iterm2 websockets
|
||||||
|
echo " 已装到 $PY_LIB"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "== 3. 探测 API 是否可用 =="
|
||||||
|
# 给 iTerm 一点时间加载偏好
|
||||||
|
osascript -e 'tell application "iTerm" to activate' >/dev/null 2>&1 || true
|
||||||
|
sleep 2
|
||||||
|
COOKIE=$(osascript -e 'tell application "iTerm" to request cookie' 2>/dev/null | grep -v TISFile | tr -d '[:space:]')
|
||||||
|
if [ -n "$COOKIE" ]; then
|
||||||
|
echo " ✅ Python API 已可用(拿到 cookie)"
|
||||||
|
echo ""
|
||||||
|
echo "完成。现在可以直接用:"
|
||||||
|
echo " bash $SELF_DIR/iterm-ctl.sh info"
|
||||||
|
else
|
||||||
|
echo " ⚠️ 暂时取不到 cookie。"
|
||||||
|
echo " 若 iTerm 刚升级,可能需要完全重启一次 iTerm2(⌘Q 后重开)让 API 服务加载偏好。"
|
||||||
|
echo " 重启后再跑一次本脚本验证即可。"
|
||||||
|
fi
|
||||||
1336
skills/iterm-workspace-ctl/scripts/iterm-ctl.py
Executable file
1336
skills/iterm-workspace-ctl/scripts/iterm-ctl.py
Executable file
File diff suppressed because it is too large
Load Diff
241
skills/iterm-workspace-ctl/scripts/iterm-ctl.sh
Executable file
241
skills/iterm-workspace-ctl/scripts/iterm-ctl.sh
Executable file
@ -0,0 +1,241 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# iterm-ctl.sh — 统一入口:cookie + websocket 直连 iTerm2 Python API
|
||||||
|
#
|
||||||
|
# 新版 iTerm2(3.6+)已废弃 `launch API script named` 通道和
|
||||||
|
# `General → Scripts → Execute command enabled` 勾选。本入口改用:
|
||||||
|
# 1. osascript 'request cookie' 取 API cookie
|
||||||
|
# 2. 系统 python3 + iterm2 库 直连 websocket 执行命令
|
||||||
|
# 无需 GUI、无需重启、无需任何勾选。
|
||||||
|
#
|
||||||
|
# 唯一一次性前置:开启 Python API(可用 install.sh 自动做):
|
||||||
|
# defaults write com.googlecode.iterm2 EnableAPIServer -bool true
|
||||||
|
#
|
||||||
|
# 用法: iterm-ctl.sh <命令> [参数...] 完整帮助: iterm-ctl.sh help
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PYTHON="${ITERM_CTL_PYTHON:-python3}"
|
||||||
|
|
||||||
|
USAGE='iterm-ctl — iTerm2 工作区控制(布局 / 焦点 / 输入 / tab / 窗口)
|
||||||
|
|
||||||
|
用法: iterm-ctl.sh <命令> [参数...]
|
||||||
|
|
||||||
|
命令:
|
||||||
|
info 打印当前窗口各 tab 的窗格几何布局(定位出错时先跑它)
|
||||||
|
ids 列出所有 session id 及名称(不受焦点影响)
|
||||||
|
id <pane> 打印窗格 session id(跨调用前先锚定)
|
||||||
|
split <v|h> [pane] [--focus] 分屏: v=左右两列, h=上下两行;默认后台创建不抢焦点;打印新 session id
|
||||||
|
split-id <id> <v|h> [--focus] 在指定 session 上分屏;默认不改焦点;打印新 session id
|
||||||
|
cols <N> [--focus] 当前 tab 均分为 N 列(默认不改焦点)
|
||||||
|
rows <N> [--focus] 当前 tab 均分为 N 行(默认不改焦点)
|
||||||
|
focus <pane> 聚焦指定窗格;focus-id <id> 按 id 聚焦
|
||||||
|
send <pane> <text> 向窗格输入 text 并回车(= 执行命令);send-id <id> <text> 按 id
|
||||||
|
send-raw <pane> <text> 向窗格输入 text,不回车;send-raw-id <id> <text> 按 id
|
||||||
|
key <pane> <按键...> 发按键: ctrl-c ctrl-d ctrl-z ctrl-l ctrl-enter enter esc tab up down left right;key-id <id> 按 id
|
||||||
|
close [pane] 关闭指定窗格(默认当前);close-id <id> 按 id;close-others [pane] 保留一个
|
||||||
|
capture <pane> [N] [--scrollback] 读取窗格内容(可见屏最后 N 行;--scrollback 含回滚历史);capture-id <id> 按 id
|
||||||
|
wait <pane> <pattern> [秒] 轮询窗格屏幕直到出现 pattern(默认 30 秒超时);wait-id <id> <pattern> 按 id
|
||||||
|
wait-idle <pane|id> [选项] 等目标空闲(命令跑完回提示符 / TUI 屏幕连续静止);可选随后发一条单行文本
|
||||||
|
idle-state <pane|id> 一次性读目标忙闲(job/prompt/screen_hash)
|
||||||
|
send-when-idle <命令> 把 wait-idle 后台化并管生命周期:start/status/logs/stop(send-when-idle help)
|
||||||
|
batch 从 stdin 一次连接顺序执行多条命令;VAR=$(cmd) 捕获、$VAR 引用、sleep N 等待
|
||||||
|
tab-new [--focus] [--profile P] [--command C] 新建 tab(默认后台创建不抢焦点);打印新 session id
|
||||||
|
tab-close [N] 关闭第 N 个 tab(默认当前)
|
||||||
|
tab-select <N> 切换到第 N 个 tab
|
||||||
|
tab-list 列出 tab 及窗格数
|
||||||
|
tab-detach <N> 把第 N 个 tab 独立成新窗口;打印新窗口 id
|
||||||
|
win-new [--profile P] [--command C] 新建窗口
|
||||||
|
win-close [n|id] 关闭窗口(默认当前)
|
||||||
|
win-select <n|id> 激活窗口;app-activate 把 iTerm 前置
|
||||||
|
where [--json] 当前 win/tab/pane 编号与 session 信息(回答“我在哪”)
|
||||||
|
tree [--json] [--win 窗口] [--tab tab] 全量/指定范围的完整结构快照
|
||||||
|
tab-list [--json] 当前窗口 tab 一览(含相对当前的位置 current±N)
|
||||||
|
tab-show <first|last|N> 某个 tab 的窗格/布局/进程详情(直接可读,无需解析)
|
||||||
|
win-list [--json] 窗口一览;win-show <first|last|N> 看某个窗口详情
|
||||||
|
capture-tab <first|last|N> [行数] [--scrollback] 直接读某个 tab 的屏幕/历史
|
||||||
|
send-tab <first|last|N> "命令" 直接给某个 tab 发命令(不需 id)
|
||||||
|
key-tab <first|last|N> 按键 直接给某个 tab 发按键
|
||||||
|
profiles 列出可用 profile 名称
|
||||||
|
scrollback [list|unlimited|set N] [--profile P] 读/改各 profile 的回滚缓冲(对新建会话生效)
|
||||||
|
var <pane|id> <名字> 读 session 变量;set-var <pane|id> <名字> <值> 写入
|
||||||
|
title <pane|id> <文字> 设置 session 名称;tab-title <N> <文字>;win-title [n|id] <文字>
|
||||||
|
resize <pane|id> <列> <行> 调整窗格字符尺寸
|
||||||
|
frame [n|id] [x y w h] 读/设置窗口位置与大小
|
||||||
|
fullscreen <n|id> on|off 窗口全屏开关
|
||||||
|
help [命令] 显示帮助(不带参数=总览,带参数=该命令详情)
|
||||||
|
|
||||||
|
pane 定位(按窗格几何坐标,不依赖序号):
|
||||||
|
cur(默认,当前聚焦) left right top bottom center N(1起)
|
||||||
|
|
||||||
|
⚠️ pane 方位相对「当前聚焦 tab」实时解析;跨多次调用请先用 id/ids 锚定 session id,
|
||||||
|
之后一律用 *-id 命令,否则用户切换 tab 后命令会发到错误的窗格。
|
||||||
|
⚠️ 创建类命令(split/split-id/cols/rows/tab-new)默认后台创建、不改变用户当前焦点;
|
||||||
|
只有显式加 --focus 才切过去。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
iterm-ctl.sh split v 左右分屏(后台创建,焦点不变)
|
||||||
|
iterm-ctl.sh split h right --focus 右边窗格上下分并切过去
|
||||||
|
iterm-ctl.sh cols 3 左中右三栏(焦点不变)
|
||||||
|
iterm-ctl.sh send right "pi" 在当前 tab 的右窗格启动 pi
|
||||||
|
iterm-ctl.sh close right 关掉右边的窗格
|
||||||
|
iterm-ctl.sh batch <<'EOF' 多步任务默认用 batch 一次连接完成
|
||||||
|
T=$(tab-new)
|
||||||
|
R=$(split-id "$T" v)
|
||||||
|
send-id "$T" "pi"
|
||||||
|
send-id "$R" "proxy"
|
||||||
|
sleep 2
|
||||||
|
send-id "$R" "codex"
|
||||||
|
EOF
|
||||||
|
WS=$(iterm-ctl.sh id right) && iterm-ctl.sh send-id "$WS" "pi" && iterm-ctl.sh capture-id "$WS" 10
|
||||||
|
# ↑ 跨调用工作流:先锚定 id,之后不受前台焦点影响'
|
||||||
|
|
||||||
|
CMD_HELP='split: split <v|h> [pane] [--focus] 分屏;默认保持当前焦点,--focus 切过去;打印新 session id
|
||||||
|
split-id: split-id <id> <v|h> [--focus] 在指定 session 上分屏;打印新 session id
|
||||||
|
cols: cols <N> [--focus] 当前 tab 均分为 N 列(iTerm 固定对半切,N=3 时 25/25/50)
|
||||||
|
rows: rows <N> [--focus] 当前 tab 均分为 N 行
|
||||||
|
focus: focus <pane> 聚焦窗格: cur|left|right|top|bottom|center|N
|
||||||
|
focus-id: focus-id <id> 聚焦指定 session id
|
||||||
|
id: id <pane> 打印窗格 session id(默认当前聚焦窗格)
|
||||||
|
ids: ids 列出所有窗口/tab 的 session id、名称与焦点标记
|
||||||
|
send: send <pane> <text> 输入 text 并回车(多词 text 用引号包成一个参数)
|
||||||
|
send-id: send-id <id> <text> 同上,按 session id 定位(不受焦点影响)
|
||||||
|
send-raw: send-raw <pane> <text> 输入 text 不回车
|
||||||
|
send-raw-id: send-raw-id <id> <text> 同上,按 session id 定位
|
||||||
|
key: key <pane> <按键...> 发按键(可连发): ctrl-c ctrl-d ctrl-z ctrl-l ctrl-enter enter esc tab up down left right
|
||||||
|
key-id: key-id <id> <按键...> 同上,按 session id 定位
|
||||||
|
close: close [pane] 关闭窗格: cur(默认)|left|right|top|bottom|center|N
|
||||||
|
close-id: close-id <id> 关闭指定 session id
|
||||||
|
close-others: close-others [pane] 关闭除指定窗格外的所有窗格(默认保留当前,force 不弹确认)
|
||||||
|
capture: capture <pane> [N] [--scrollback] 读窗格内容(默认可见屏最后30行;--scrollback 含回滚)
|
||||||
|
capture-id: capture-id <id> [N] [--scrollback] 同上,按 session id 定位
|
||||||
|
tab-list: tab-list [--json] 当前窗口 tab 一览:编号/名称/窗格数/布局/相对当前位置
|
||||||
|
tab-show: tab-show <first|last|N> 某个 tab 的详情(窗格数/布局/session/进程/路径)
|
||||||
|
win-list: win-list [--json] 窗口一览:编号/tab 数/位置尺寸/全屏
|
||||||
|
win-show: win-show <first|last|N> 某个窗口详情(等价 tree --win)
|
||||||
|
capture-tab: capture-tab <first|last|N> [行数] [--scrollback] 读某个 tab 的屏幕/历史
|
||||||
|
send-tab: send-tab <first|last|N> "命令" 给某个 tab 发命令并回车
|
||||||
|
key-tab: key-tab <first|last|N> 按键... 给某个 tab 发按键
|
||||||
|
tree: tree [--json] [--win n|id|first|last] [--tab first|last|N] 结构快照;常见问题直接用 tab-show/win-show
|
||||||
|
tree-json: tree --json 输出完整机器可读结构(窗口/tab/窗格)
|
||||||
|
where: where [--json] 当前 win/tab/pane 编号与 session 信息(“我在哪/第几个 tab”)
|
||||||
|
profiles: profiles 列出可用 profile 名称(配合 --profile)
|
||||||
|
scrollback: scrollback [list|unlimited|set N] [--profile P] 读/改各 profile 的回滚缓冲(影响新开会话)
|
||||||
|
例: scrollback list / scrollback unlimited / scrollback set 1000 / scrollback --profile Dark unlimited
|
||||||
|
var: var <pane|id> <名字> 读 session 变量(jobName/path/tty/user.* 等)
|
||||||
|
set-var: set-var <pane|id> <名字> <值> 写 session 变量
|
||||||
|
title: title <pane|id> <文字> 设置 session 名称(tab 标题来源)
|
||||||
|
tab-title: tab-title <N> <文字> 设置当前窗口第 N 个 tab 标题
|
||||||
|
win-title: win-title [n|id] <文字> 设置窗口标题
|
||||||
|
resize: resize <pane|id> <列> <行> 调整窗格字符尺寸(grid)
|
||||||
|
frame: frame [n|id] [x y w h] 读/设置窗口位置与大小
|
||||||
|
fullscreen: fullscreen <n|id> on|off 窗口全屏开关
|
||||||
|
win-select: win-select <n|id> 激活窗口(前置);app-activate 把整个 iTerm 前置
|
||||||
|
tab-select: tab-select <first|last|N> 切换到指定 tab(默认当前)
|
||||||
|
tab-close: tab-close <first|last|N> 关闭指定 tab(默认当前)
|
||||||
|
tab-detach: tab-detach <N> 把第 N 个 tab 独立成新窗口;打印新窗口 id
|
||||||
|
wait: wait <pane> <pattern> [秒] 轮询直到出现 pattern(子串匹配)
|
||||||
|
wait-id: wait-id <id> <pattern> [秒] 同上,按 session id 定位
|
||||||
|
wait-idle: wait-idle <pane|id> [--when auto|prompt|quiet] [--quiet 秒] [--confirm 秒] [--poll 秒] [--max 秒]
|
||||||
|
[--send 文本 | --file 文件] [--state-file 路径] 等目标空闲(前台阻塞);给了文本就发单行并回车
|
||||||
|
idle-state: idle-state <pane|id> 一次性读目标忙闲:session/job/prompt/screen_hash/screen_lines
|
||||||
|
send-when-idle: send-when-idle start <pane|id> "文本" [选项] 后台守候,空闲后发进去并回车
|
||||||
|
status [id|--all] / logs <id> [行数] / stop <id|--all> 选项与判定语义见 send-when-idle help
|
||||||
|
status 里的 as_of=最近一次采样时间(守候还活着吗的 liveness 证据)
|
||||||
|
batch: batch 从 stdin 一次连接顺序执行多条命令(引号 heredoc)
|
||||||
|
支持 VAR=$(cmd) 捕获、$VAR 引用、sleep N;任一行失败即中止
|
||||||
|
例: iterm-ctl.sh batch <<'EOF' / T=$(tab-new) / send-id "$T" "pi" / EOF
|
||||||
|
tab-new: tab-new [--focus] [--profile P] [--command C] 新建 tab(默认后台创建不抢焦点);打印新 session id
|
||||||
|
tab-close: tab-close [N] 关闭第 N 个 tab(默认当前)
|
||||||
|
tab-select: tab-select <N> 切换到第 N 个 tab
|
||||||
|
tab-list: tab-list 列出 tab 及窗格数
|
||||||
|
win-new: win-new [--profile P] [--command C] 新建窗口
|
||||||
|
win-close: win-close [n|id] 关闭窗口(默认当前窗口,会终止窗格进程)
|
||||||
|
info: info 打印当前窗口窗格几何布局(调试定位用)'
|
||||||
|
|
||||||
|
if [ "${1:-}" = "help" ]; then
|
||||||
|
if [ $# -ge 2 ]; then
|
||||||
|
hit=$(grep "^${2}:" <<< "$CMD_HELP" || true)
|
||||||
|
if [ -n "$hit" ]; then echo "$hit"; else echo "未知命令: $2(可用: split split-id cols rows focus focus-id id ids send send-id send-raw send-raw-id key key-id close close-id close-others capture capture-id capture-tab send-tab key-tab wait wait-id wait-idle idle-state send-when-idle batch tab-new tab-close tab-select tab-list tab-show tab-detach win-new win-close win-select win-list win-show app-activate where tree profiles scrollback var set-var title tab-title win-title resize frame fullscreen info)"; exit 1; fi
|
||||||
|
else
|
||||||
|
echo "$USAGE"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ $# -lt 1 ]; then
|
||||||
|
echo "$USAGE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# send-when-idle(带台账的后台守候)实现独立在 send-when-idle.sh,这里只转发,不进热路径
|
||||||
|
if [ "${1:-}" = "send-when-idle" ]; then
|
||||||
|
shift
|
||||||
|
exec bash "$SELF_DIR/send-when-idle.sh" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 热路径:这条命令每次调用都要跑一遍,凡是能省掉的都省掉 ----
|
||||||
|
# 实测(同一台机,见 skills/iterm-workspace-ctl/REFERENCE.md):
|
||||||
|
# iTerm2 的 cookie 是一次性令牌 —— 现取一个只能让"紧接着的那次"连接 0.05s,
|
||||||
|
# 同一个 cookie 再用一次要 0.55s(iTerm2 会走回退路径),不带 cookie 0.27s。
|
||||||
|
# 而现取 cookie 本身要付 0.8s osascript。所以:**不带 cookie 最快**,
|
||||||
|
# 只有在连接真的被拒时才去取一个现的来重试。
|
||||||
|
# (旧实现每次无条件取 cookie 并把它设成 ITERM_COOKIE —— 库读的是 ITERM2_COOKIE,
|
||||||
|
# 等于每次白付 0.8s,还照样走慢连接。)
|
||||||
|
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/Library/Caches}/iterm-workspace-ctl"
|
||||||
|
|
||||||
|
# 依赖目录:系统解释器没有 iterm2 时才用机器缓存里的副本(纯 python 包,不写进共享源码)
|
||||||
|
LIB_HINT="$CACHE_DIR/.libpath"
|
||||||
|
PY_LIB=""
|
||||||
|
if [ -f "$LIB_HINT" ]; then PY_LIB="$(cat "$LIB_HINT" 2>/dev/null || true)"; fi
|
||||||
|
if [ -z "$PY_LIB" ] || [ ! -d "$PY_LIB/iterm2" ]; then
|
||||||
|
PY_LIB="$(bash "$SELF_DIR/python-library-path.sh" path)"
|
||||||
|
if [ ! -d "$PY_LIB/iterm2" ]; then
|
||||||
|
mkdir -p "$PY_LIB"
|
||||||
|
echo "iterm-ctl: 首次运行,安装 iterm2 库到 $PY_LIB ..." >&2
|
||||||
|
"$PYTHON" -m pip install --quiet --target "$PY_LIB" iterm2 websockets >&2
|
||||||
|
fi
|
||||||
|
mkdir -p "$CACHE_DIR"
|
||||||
|
printf '%s\n' "$PY_LIB" > "$LIB_HINT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 定位当前窗口。AppleScript 的数字 id ≠ Python API 的 window_id(类型不同),
|
||||||
|
# 无法互转,故统一传 "current",在 Python 侧用 app.current_window。
|
||||||
|
WIN="current"
|
||||||
|
|
||||||
|
if [ -n "${ITERM2_COOKIE:-}" ] && [ -z "${ITERM_CTL_FRESH_COOKIE:-}" ]; then
|
||||||
|
# 调用方(iTerm2 自己的会话)给的是已用过/会过期的 cookie,留着只会走慢路径
|
||||||
|
unset ITERM2_COOKIE
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_py() { PYTHONPATH="$PY_LIB" "$PYTHON" "$SELF_DIR/iterm-ctl.py" "$WIN" "$@"; }
|
||||||
|
|
||||||
|
# 失败:可能是“没带 cookie 被拒/需授权”,也可能是命令本身报错。
|
||||||
|
# 两种情况分开处理:我们自己 die() 的消息带 `iterm-ctl: ` 前缀(参数错、找不到窗格……),
|
||||||
|
# 重试没意义 → 直接报错;没有该前缀(连接被拒/授权失败)→ 取一个现的 cookie 重试一次。
|
||||||
|
# 输出先落盘再转发:成功时也要原样还回去(stdout/stderr 分开,顺序按各自通道)。
|
||||||
|
OUT_LOG="$(mktemp -t iterm-ctl-out)"
|
||||||
|
ERR_LOG="$(mktemp -t iterm-ctl-err)"
|
||||||
|
trap 'rm -f "$OUT_LOG" "$ERR_LOG"' EXIT
|
||||||
|
relay() { cat "$OUT_LOG"; cat "$ERR_LOG" >&2; }
|
||||||
|
|
||||||
|
if run_py "$@" >"$OUT_LOG" 2>"$ERR_LOG"; then
|
||||||
|
relay
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if grep -q '^iterm-ctl: ' "$OUT_LOG" "$ERR_LOG"; then
|
||||||
|
relay
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
COOKIE="$(osascript -e 'tell application "iTerm" to request cookie' 2>/dev/null | grep -v TISFile | tr -d '[:space:]' || true)"
|
||||||
|
if [ -z "$COOKIE" ]; then
|
||||||
|
echo "iterm-ctl: 取不到 API cookie。Python API 未开启,运行: bash $SELF_DIR/install.sh run" >&2
|
||||||
|
relay
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# 同一个进程里立刻用掉这个新 cookie(一次性令牌:紧跟的那次连接才快)
|
||||||
|
export ITERM2_COOKIE="$COOKIE" ITERM_CTL_FRESH_COOKIE=1
|
||||||
|
if run_py "$@" >"$OUT_LOG" 2>"$ERR_LOG"; then
|
||||||
|
relay
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
relay
|
||||||
|
exit 1
|
||||||
12
skills/iterm-workspace-ctl/scripts/python-library-path.sh
Normal file
12
skills/iterm-workspace-ctl/scripts/python-library-path.sh
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Resolve only the private dependency path; does not install or enable any API.
|
||||||
|
set -euo pipefail
|
||||||
|
case "${1:-help}" in
|
||||||
|
help) printf '%s\n' 'Usage: python-library-path.sh path | help [path]' 'Print ITERM_CTL_PY_LIB or the per-interpreter macOS cache directory.'; exit 0 ;;
|
||||||
|
path) ;;
|
||||||
|
*) echo 'Unknown command; use help' >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
if [ -n "${ITERM_CTL_PY_LIB:-}" ]; then printf '%s\n' "$ITERM_CTL_PY_LIB"; exit 0; fi
|
||||||
|
PYTHON="${ITERM_CTL_PYTHON:-python3}"
|
||||||
|
TAG="$("$PYTHON" -c 'import sys,platform; print(sys.implementation.cache_tag + "-" + platform.machine())')"
|
||||||
|
printf '%s/iterm-workspace-ctl/%s\n' "${XDG_CACHE_HOME:-$HOME/Library/Caches}" "$TAG"
|
||||||
260
skills/iterm-workspace-ctl/scripts/send-when-idle.sh
Normal file
260
skills/iterm-workspace-ctl/scripts/send-when-idle.sh
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# send-when-idle — 等一个终端 session 空闲,再往它里面发一句话(后台守候 + 台账管理)
|
||||||
|
#
|
||||||
|
# 原语在 iterm-ctl.sh 的 wait-idle / idle-state;本脚本只负责:
|
||||||
|
# 把 wait-idle 后台化、把每个任务的进程与状态记进台账、提供 status/logs/stop。
|
||||||
|
# 目标是通用的:任何 tab/窗格/session、任何前台命令都适用(判定只看前台进程名与屏幕内容)。
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
CTL="${ITERM_CTL_CTL:-$SELF_DIR/iterm-ctl.sh}"
|
||||||
|
JOBS_DIR="${ITERM_CTL_JOBS_DIR:-$HOME/.pi/agent/local/skills/iterm-workspace-ctl/jobs}"
|
||||||
|
|
||||||
|
USAGE="send-when-idle — 等一个终端 session 空闲后,把一句话发进去(任何 pane / session)
|
||||||
|
|
||||||
|
用法: send-when-idle <命令> [参数...]
|
||||||
|
|
||||||
|
命令:
|
||||||
|
start <pane|id> <文本...> [选项] 后台守候;目标空闲后把文本发进去并回车
|
||||||
|
start <pane|id> --file 文件 [选项] 文本从文件读(单行;末尾换行会去掉)
|
||||||
|
status [id|--all] 看任务台账(默认全部)
|
||||||
|
logs <id> [行数] 看某个任务的日志(默认 30 行)
|
||||||
|
stop <id|--all> 停掉守候(只杀守候进程,不碰目标 session)
|
||||||
|
help 本帮助
|
||||||
|
|
||||||
|
选项(start):
|
||||||
|
--when auto|prompt|quiet 空闲判定,默认 auto
|
||||||
|
auto = 回到 shell 提示符,或屏幕连续静止
|
||||||
|
prompt = 只认命令跑完、回到 shell 提示符
|
||||||
|
quiet = 只认屏幕连续静止(TUI 停在输入框)
|
||||||
|
--quiet 秒 屏幕静止多少秒算空闲(默认 120)
|
||||||
|
--confirm 秒 判定空闲后复核多久再发(默认 30;0=立即发)
|
||||||
|
--poll 秒 采样间隔(默认 10)
|
||||||
|
--max 秒 最长守候(默认 21600 = 6 小时;0 = 不限)
|
||||||
|
|
||||||
|
判定语义:
|
||||||
|
目标前台是 shell(jobName=zsh/bash/...)→ 命令已跑完、回到提示符;
|
||||||
|
其他前台程序(pi/codex/vim/...) → 可见屏连续静止 --quiet 秒 = 停在它自己的输入框。
|
||||||
|
静默不输出的长命令会被 quiet 误判成空闲;要绝对安全就用 --when prompt。
|
||||||
|
|
||||||
|
文本必须是单行:含换行会在目标里提前提交,脚本会直接拒绝。
|
||||||
|
|
||||||
|
台账目录: $JOBS_DIR
|
||||||
|
每个任务一个 id,含 .meta(参数/pid)/.text(要发的话)/.log(守候日志)
|
||||||
|
/.state(最近一次采样,status 里的 as_of 就是它)/.result(退出码)。
|
||||||
|
只留最近 30 个任务:更旧的、已结束的会在下次 start 时自动清掉。"
|
||||||
|
|
||||||
|
die() { echo "send-when-idle: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
is_sid() { [[ "$1" =~ ^(w[0-9]+t[0-9]+p[0-9]+:)?[0-9A-Fa-f]{8}-[0-9A-Fa-f-]{20,}$ ]]; }
|
||||||
|
|
||||||
|
resolve_sid() {
|
||||||
|
local spec="$1" sid
|
||||||
|
if is_sid "$spec"; then printf '%s' "${spec##*:}"; return 0; fi
|
||||||
|
sid="$(bash "$CTL" id "$spec" 2>/dev/null)"
|
||||||
|
[ -n "$sid" ] || return 1
|
||||||
|
printf '%s' "$sid"
|
||||||
|
}
|
||||||
|
|
||||||
|
meta_val() { sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1; }
|
||||||
|
|
||||||
|
job_pid() { meta_val "$JOBS_DIR/$1.meta" pid; }
|
||||||
|
|
||||||
|
alive() { [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; }
|
||||||
|
|
||||||
|
kill_tree() {
|
||||||
|
local pid="$1" kid
|
||||||
|
for kid in $(pgrep -P "$pid" 2>/dev/null); do kill_tree "$kid"; done
|
||||||
|
kill -TERM "$pid" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
prune_jobs() {
|
||||||
|
local m id
|
||||||
|
for m in $(ls -1t "$JOBS_DIR"/*.meta 2>/dev/null | tail -n +31); do
|
||||||
|
id="$(basename "$m" .meta)"
|
||||||
|
[ -f "$JOBS_DIR/$id.result" ] || continue
|
||||||
|
alive "$(meta_val "$m" pid)" && continue
|
||||||
|
rm -f "$JOBS_DIR/$id".{meta,text,state,log,result,worker.sh}
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
active_job_for() {
|
||||||
|
local sid="$1" m id
|
||||||
|
for m in "$JOBS_DIR"/*.meta; do
|
||||||
|
[ -e "$m" ] || continue
|
||||||
|
id="$(basename "$m" .meta)"
|
||||||
|
[ "$(meta_val "$m" sid)" = "$sid" ] || continue
|
||||||
|
[ -f "$JOBS_DIR/$id.result" ] && continue
|
||||||
|
if alive "$(meta_val "$m" pid)"; then printf '%s' "$id"; return 0; fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt_secs() {
|
||||||
|
local s="${1%%.*}"
|
||||||
|
if [ -z "$s" ] || [ "$s" -lt 0 ] 2>/dev/null; then echo "?"; return; fi
|
||||||
|
if [ "$s" -lt 60 ]; then echo "${s}s"; elif [ "$s" -lt 3600 ]; then echo "$((s/60))m$((s%60))s"; else echo "$((s/3600))h$((s%3600/60))m"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_start() {
|
||||||
|
local spec="" text="" file="" when="auto" quiet="120" confirm="30" poll="10" max="21600"
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--file) file="${2:-}"; shift 2 ;;
|
||||||
|
--when) when="${2:-}"; shift 2 ;;
|
||||||
|
--quiet) quiet="${2:-}"; shift 2 ;;
|
||||||
|
--confirm) confirm="${2:-}"; shift 2 ;;
|
||||||
|
--poll) poll="${2:-}"; shift 2 ;;
|
||||||
|
--max) max="${2:-}"; shift 2 ;;
|
||||||
|
--*) die "未知选项: $1(send-when-idle help)" ;;
|
||||||
|
*) if [ -z "$spec" ]; then spec="$1"; else text="$text $1"; fi; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
text="${text# }"
|
||||||
|
[ -n "$spec" ] || die "start 需要 <pane|id> <文本...>"
|
||||||
|
case "$when" in auto|prompt|quiet) ;; *) die "--when 只能是 auto|prompt|quiet" ;; esac
|
||||||
|
local v
|
||||||
|
for v in "$quiet" "$confirm" "$poll" "$max"; do
|
||||||
|
case "$v" in ''|*[!0-9.]*) die "--quiet/--confirm/--poll/--max 需要秒数,收到: $v" ;; esac
|
||||||
|
done
|
||||||
|
if [ -n "$file" ] && [ -n "$text" ]; then die "文本和 --file 只能给一个"; fi
|
||||||
|
if [ -n "$file" ]; then
|
||||||
|
[ -r "$file" ] || die "读不到文件: $file"
|
||||||
|
case "$(cat "$file")" in *$'\n'*) die "文本必须是单行($file 含多行,换行会在目标里提前提交)" ;; esac
|
||||||
|
[ -n "$(cat "$file")" ] || die "文本为空: $file"
|
||||||
|
fi
|
||||||
|
if [ -z "$file" ] && [ -z "$text" ]; then die "start 需要 <pane|id> <文本...> 或 --file 文件"; fi
|
||||||
|
if [ -z "$file" ]; then
|
||||||
|
case "$text" in *$'\n'*) die "文本必须是单行(含换行会在目标里提前提交)" ;; esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
local sid id existing
|
||||||
|
sid="$(resolve_sid "$spec")" || die "定位不到目标 $spec(用 iterm-workspace-ctl ids 看 session id)"
|
||||||
|
if existing="$(active_job_for "$sid")"; then
|
||||||
|
die "目标 $sid 已有守候中的任务 id=$existing(先 send-when-idle status / stop $existing)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
prune_jobs
|
||||||
|
|
||||||
|
id="$(printf '%s' "$sid" | cut -c1-8)-$(date +%H%M%S)"
|
||||||
|
mkdir -p "$JOBS_DIR"
|
||||||
|
local textfile="$JOBS_DIR/$id.text" state="$JOBS_DIR/$id.state" log="$JOBS_DIR/$id.log" \
|
||||||
|
meta="$JOBS_DIR/$id.meta" result="$JOBS_DIR/$id.result" worker="$JOBS_DIR/$id.worker.sh"
|
||||||
|
if [ -n "$file" ]; then cp "$file" "$textfile" || die "复制文本失败: $file"; else printf '%s\n' "$text" > "$textfile"; fi
|
||||||
|
local preview
|
||||||
|
preview="$(head -c 200 "$textfile" | tr -d '\n')"
|
||||||
|
|
||||||
|
cat > "$worker" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
bash $(printf '%q' "$CTL") wait-idle $(printf '%q' "$sid") --when $when --quiet $quiet --confirm $confirm --poll $poll --max $max \\
|
||||||
|
--state-file $(printf '%q' "$state") --file $(printf '%q' "$textfile") >> $(printf '%q' "$log") 2>&1
|
||||||
|
ec=\$?
|
||||||
|
echo "exit=\$ec at=\$(date '+%F %T')" >> $(printf '%q' "$log")
|
||||||
|
printf 'exit=%s\nat=%s\n' "\$ec" "\$(date '+%F %T')" > $(printf '%q' "$result")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$meta" <<EOF
|
||||||
|
id=$id
|
||||||
|
sid=$sid
|
||||||
|
target=$spec
|
||||||
|
when=$when
|
||||||
|
quiet=$quiet
|
||||||
|
confirm=$confirm
|
||||||
|
poll=$poll
|
||||||
|
max=$max
|
||||||
|
started=$(date '+%F %T')
|
||||||
|
started_epoch=$(date +%s)
|
||||||
|
text=$preview
|
||||||
|
text_file=$textfile
|
||||||
|
state_file=$state
|
||||||
|
log=$log
|
||||||
|
result=$result
|
||||||
|
worker=$worker
|
||||||
|
EOF
|
||||||
|
|
||||||
|
nohup bash "$worker" </dev/null >/dev/null 2>&1 &
|
||||||
|
echo "pid=$!" >> "$meta"
|
||||||
|
echo "id=$id"
|
||||||
|
echo "pid=$(meta_val "$meta" pid)"
|
||||||
|
echo "target=$sid"
|
||||||
|
echo "log=$log"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_job() {
|
||||||
|
local id="$1" m="$JOBS_DIR/$1.meta" pid state ec st
|
||||||
|
pid="$(meta_val "$m" pid)"
|
||||||
|
if [ -f "$JOBS_DIR/$id.result" ]; then
|
||||||
|
ec="$(meta_val "$JOBS_DIR/$id.result" exit)"
|
||||||
|
case "$ec" in
|
||||||
|
0) state="sent (已发送)" ;;
|
||||||
|
stopped) state="stopped (已停)" ;;
|
||||||
|
*) state="exit=$ec (未发送)" ;;
|
||||||
|
esac
|
||||||
|
elif alive "$pid"; then
|
||||||
|
st="$(meta_val "$m" started_epoch)"
|
||||||
|
local waited=""
|
||||||
|
[ -n "$st" ] && waited=" 已守=$(fmt_secs "$(( $(date +%s) - st ))")"
|
||||||
|
state="running (pid $pid$waited)"
|
||||||
|
else
|
||||||
|
state="stopped (无进程,未见结果)"
|
||||||
|
fi
|
||||||
|
echo "id=$id state=$state"
|
||||||
|
echo " target=$(meta_val "$m" sid) 起=$(meta_val "$m" started)"
|
||||||
|
echo " 判定 when=$(meta_val "$m" when) quiet=$(meta_val "$m" quiet)s confirm=$(meta_val "$m" confirm)s max=$(meta_val "$m" max)s"
|
||||||
|
if [ -f "$JOBS_DIR/$id.state" ]; then
|
||||||
|
local f="$JOBS_DIR/$id.state"
|
||||||
|
echo " as_of=$(meta_val "$f" as_of) job=$(meta_val "$f" job) prompt=$(meta_val "$f" prompt) stable=$(meta_val "$f" stable_sec)s waited=$(meta_val "$f" waited_sec)s verdict=$(meta_val "$f" verdict)"
|
||||||
|
fi
|
||||||
|
echo " 文本: $(meta_val "$m" text)"
|
||||||
|
echo " 日志: $(meta_val "$m" log)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_status() {
|
||||||
|
local want="${1:-}" m id found=0
|
||||||
|
for m in "$JOBS_DIR"/*.meta; do
|
||||||
|
[ -e "$m" ] || continue
|
||||||
|
id="$(basename "$m" .meta)"
|
||||||
|
if [ -n "$want" ] && [ "$want" != "--all" ] && [ "$id" != "$want" ]; then continue; fi
|
||||||
|
found=1
|
||||||
|
print_job "$id"
|
||||||
|
done
|
||||||
|
[ "$found" = 1 ] || echo "没有任务(send-when-idle start <pane|id> \"文本\";台账: $JOBS_DIR)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_logs() {
|
||||||
|
local id="${1:-}" n="${2:-30}"
|
||||||
|
[ -n "$id" ] || die "logs 需要 <id>"
|
||||||
|
[ -f "$JOBS_DIR/$id.log" ] || die "没有这个任务: $id"
|
||||||
|
tail -n "$n" "$JOBS_DIR/$id.log"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_stop() {
|
||||||
|
local want="${1:-}" m id pid found=0
|
||||||
|
[ -n "$want" ] || die "stop 需要 <id|--all>"
|
||||||
|
for m in "$JOBS_DIR"/*.meta; do
|
||||||
|
[ -e "$m" ] || continue
|
||||||
|
id="$(basename "$m" .meta)"
|
||||||
|
if [ "$want" != "--all" ] && [ "$id" != "$want" ]; then continue; fi
|
||||||
|
found=1
|
||||||
|
pid="$(meta_val "$m" pid)"
|
||||||
|
if [ -f "$JOBS_DIR/$id.result" ]; then
|
||||||
|
echo "id=$id 已经结束($(meta_val "$JOBS_DIR/$id.result" exit)),无需停止"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if alive "$pid"; then kill_tree "$pid"; sleep 0.5; fi
|
||||||
|
printf 'exit=stopped\nat=%s\n' "$(date '+%F %T')" > "$JOBS_DIR/$id.result"
|
||||||
|
echo "已停止 id=$id(pid $pid,目标 session 不受影响)"
|
||||||
|
done
|
||||||
|
[ "$found" = 1 ] || die "没有这个任务: $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd="${1:-help}"
|
||||||
|
[ $# -gt 0 ] && shift
|
||||||
|
case "$cmd" in
|
||||||
|
start) cmd_start "$@" ;;
|
||||||
|
status) cmd_status "$@" ;;
|
||||||
|
logs) cmd_logs "$@" ;;
|
||||||
|
stop) cmd_stop "$@" ;;
|
||||||
|
help|-h|--help) echo "$USAGE" ;;
|
||||||
|
*) echo "$USAGE"; echo; die "未知命令: $cmd" ;;
|
||||||
|
esac
|
||||||
Loading…
Reference in New Issue
Block a user