tmuxctl 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tmuxctl-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,245 @@
1
+ Metadata-Version: 2.4
2
+ Name: tmuxctl
3
+ Version: 0.1.1
4
+ Summary: Small tmux session controller with recurring sends
5
+ Author: Alexey Grigorev
6
+ Project-URL: Homepage, https://github.com/alexeygrigorev/tmuxctl
7
+ Project-URL: Issues, https://github.com/alexeygrigorev/tmuxctl/issues
8
+ Keywords: tmux,cli,scheduler,automation
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: System :: Shells
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: typer<1,>=0.12
22
+
23
+ # tmuxctl
24
+
25
+ Small tmux session controller with recurring sends.
26
+
27
+ `tmuxctl` lets you:
28
+
29
+ - list tmux sessions by name
30
+ - show the most recent sessions by creation time or activity
31
+ - attach to a named session or jump to the most recent ones quickly
32
+ - send a message to a session's active pane
33
+ - store recurring jobs in SQLite
34
+ - run a lightweight daemon loop that executes due jobs
35
+
36
+ ## Install
37
+
38
+ Install from PyPI with `uv`:
39
+
40
+ ```bash
41
+ uv tool install tmuxctl
42
+ tmuxctl --help
43
+ ```
44
+
45
+ Or with `pip`:
46
+
47
+ ```bash
48
+ pip install tmuxctl
49
+ tmuxctl --help
50
+ ```
51
+
52
+ Install from GitHub with `uv`:
53
+
54
+ ```bash
55
+ uv tool install git+https://github.com/alexeygrigorev/tmuxctl.git
56
+ tmuxctl --help
57
+ ```
58
+
59
+ Install from a local checkout in editable mode:
60
+
61
+ ```bash
62
+ git clone https://github.com/alexeygrigorev/tmuxctl.git
63
+ cd tmuxctl
64
+ uv tool install -e .
65
+ tmuxctl --help
66
+ ```
67
+
68
+ If you update the local checkout later, reinstall with:
69
+
70
+ ```bash
71
+ uv tool install -e . --force
72
+ ```
73
+
74
+ For development, tests, and builds:
75
+
76
+ ```bash
77
+ uv sync --dev
78
+ uv run pytest
79
+ uv build
80
+ ```
81
+
82
+ ## Usage
83
+
84
+ List sessions:
85
+
86
+ ```bash
87
+ tmuxctl list
88
+ tmuxctl recent --limit 10
89
+ tmuxctl recent --limit 10 --by activity
90
+ ```
91
+
92
+ Attach to a session directly or jump to the newest one:
93
+
94
+ ```bash
95
+ tmuxctl attach codex
96
+ tmuxctl create-or-attach codex
97
+ tmuxctl :codex
98
+ tmuxctl attach-last
99
+ tmuxctl attach-last --by activity
100
+ tmuxctl attach-recent 2
101
+ tmuxctl attach-recent 3 --by activity
102
+ ```
103
+
104
+ `tmuxctl :codex` is shorthand for `tmuxctl create-or-attach codex`.
105
+
106
+ There are also short hidden aliases for the most recent sessions:
107
+
108
+ ```bash
109
+ tmuxctl a1
110
+ tmuxctl a2
111
+ tmuxctl a3
112
+ ```
113
+
114
+ Send one message now:
115
+
116
+ ```bash
117
+ tmuxctl send codex "check status and fix if something is broken or stuck"
118
+ ```
119
+
120
+ Send one message from a file:
121
+
122
+ ```bash
123
+ tmuxctl send rk-codex --message-file prompts/rk-codex-progress.txt
124
+ ```
125
+
126
+ By default, `tmuxctl send` waits `200ms` before pressing Return. Override that with `--enter-delay-ms` or disable Return with `--no-enter`.
127
+
128
+ Create a recurring job:
129
+
130
+ ```bash
131
+ tmuxctl add codex --every 15m --message "check status and fix if something is broken or stuck"
132
+ ```
133
+
134
+ Recurring jobs also store an Enter delay. By default that is `200ms`, and you can override it with `--enter-delay-ms`.
135
+
136
+ Example: send an automated follow-up to `rk-codex` every 30 minutes:
137
+
138
+ ```bash
139
+ tmuxctl add rk-codex --every 30m --message-file prompts/rk-codex-progress.txt
140
+ ```
141
+
142
+ You can load message text from a file with `--message-file` for `tmuxctl send`, `tmuxctl add`, and `tmuxctl edit`.
143
+
144
+ For `tmuxctl add` and `tmuxctl edit`, the file path is stored with the job. Scheduled runs read the file at send time, so updating the prompt file changes future runs without recreating the job.
145
+
146
+ To switch an existing job to the shared prompt file:
147
+
148
+ ```bash
149
+ tmuxctl jobs
150
+ tmuxctl edit <job_id> --message-file prompts/rk-codex-progress.txt
151
+ ```
152
+
153
+ Example: check a worker session every 30 minutes and unblock stalled progress:
154
+
155
+ ```bash
156
+ tmuxctl add lnewly-57 --every 30m --message "Status check for litehive: report current progress, current task, and the last meaningful change. Check whether progress is stalled, not whether you personally feel stuck. Identify blockers, lack of movement, repeated retries, failing commands, broken states, or missing dependencies. If progress is stalled, choose the best next concrete action to unblock litehive and execute it. Fix any problems you can fix now, then continue the work and summarize what changed."
157
+ ```
158
+
159
+ Edit an existing job:
160
+
161
+ ```bash
162
+ tmuxctl edit 2 --every 45m
163
+ tmuxctl edit 2 --message "check status and continue"
164
+ tmuxctl edit 3 --message-file prompts/rk-codex-progress.txt
165
+ ```
166
+
167
+ Remove a job:
168
+
169
+ ```bash
170
+ tmuxctl remove 3
171
+ ```
172
+
173
+ List jobs and logs:
174
+
175
+ ```bash
176
+ tmuxctl jobs
177
+ tmuxctl logs --limit 20
178
+ ```
179
+
180
+ `tmuxctl jobs` shows whether a job uses inline text or a linked file prompt.
181
+
182
+ Logs include the target session, whether the send was manual or scheduled, whether Return was sent, the Enter delay used, and any recorded error text.
183
+
184
+ If a scheduled job fails 3 runs in a row, `tmuxctl daemon` removes it automatically.
185
+
186
+ Run the scheduler:
187
+
188
+ ```bash
189
+ tmuxctl daemon
190
+ ```
191
+
192
+ ## Shortcuts
193
+
194
+ Useful shortcuts for hopping between recent sessions:
195
+
196
+ - `tmuxctl attach-last`
197
+ - `tmuxctl attach-recent 2`
198
+ - `tmuxctl attach-recent 3`
199
+ - `tmuxctl a1`
200
+ - `tmuxctl a2`
201
+ - `tmuxctl a3`
202
+
203
+ ## How Scheduling Works
204
+
205
+ `tmuxctl` does not create cron entries and it does not require editing `crontab`.
206
+
207
+ Recurring jobs are stored in SQLite at:
208
+
209
+ ```text
210
+ ~/.config/tmuxctl/tmuxctl.db
211
+ ```
212
+
213
+ The commands work like this:
214
+
215
+ - `tmuxctl add ...` inserts a recurring job into the database
216
+ - `tmuxctl edit`, `pause`, `resume`, and `remove` update that stored job
217
+ - `tmuxctl daemon` polls the database for due jobs and runs them
218
+
219
+ That means recurring sends only happen while the daemon is running.
220
+
221
+ If you want jobs to keep running after logout or reboot, use an external process manager to keep the daemon alive, for example:
222
+
223
+ - `systemd --user` on Linux
224
+ - `launchd` on macOS
225
+ - `cron @reboot` as a fallback
226
+
227
+ Even in those setups, cron or systemd only starts `tmuxctl daemon`. The recurring schedule itself still lives in the `tmuxctl` database.
228
+
229
+ ## Bash Completion
230
+
231
+ `tmuxctl` includes shell completion through Typer.
232
+
233
+ Install completion for your current Bash setup:
234
+
235
+ ```bash
236
+ tmuxctl --install-completion
237
+ ```
238
+
239
+ Preview or manually wire the Bash completion script:
240
+
241
+ ```bash
242
+ tmuxctl --show-completion bash
243
+ ```
244
+
245
+ Session-taking commands also complete existing tmux session names in Bash.
@@ -0,0 +1,223 @@
1
+ # tmuxctl
2
+
3
+ Small tmux session controller with recurring sends.
4
+
5
+ `tmuxctl` lets you:
6
+
7
+ - list tmux sessions by name
8
+ - show the most recent sessions by creation time or activity
9
+ - attach to a named session or jump to the most recent ones quickly
10
+ - send a message to a session's active pane
11
+ - store recurring jobs in SQLite
12
+ - run a lightweight daemon loop that executes due jobs
13
+
14
+ ## Install
15
+
16
+ Install from PyPI with `uv`:
17
+
18
+ ```bash
19
+ uv tool install tmuxctl
20
+ tmuxctl --help
21
+ ```
22
+
23
+ Or with `pip`:
24
+
25
+ ```bash
26
+ pip install tmuxctl
27
+ tmuxctl --help
28
+ ```
29
+
30
+ Install from GitHub with `uv`:
31
+
32
+ ```bash
33
+ uv tool install git+https://github.com/alexeygrigorev/tmuxctl.git
34
+ tmuxctl --help
35
+ ```
36
+
37
+ Install from a local checkout in editable mode:
38
+
39
+ ```bash
40
+ git clone https://github.com/alexeygrigorev/tmuxctl.git
41
+ cd tmuxctl
42
+ uv tool install -e .
43
+ tmuxctl --help
44
+ ```
45
+
46
+ If you update the local checkout later, reinstall with:
47
+
48
+ ```bash
49
+ uv tool install -e . --force
50
+ ```
51
+
52
+ For development, tests, and builds:
53
+
54
+ ```bash
55
+ uv sync --dev
56
+ uv run pytest
57
+ uv build
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ List sessions:
63
+
64
+ ```bash
65
+ tmuxctl list
66
+ tmuxctl recent --limit 10
67
+ tmuxctl recent --limit 10 --by activity
68
+ ```
69
+
70
+ Attach to a session directly or jump to the newest one:
71
+
72
+ ```bash
73
+ tmuxctl attach codex
74
+ tmuxctl create-or-attach codex
75
+ tmuxctl :codex
76
+ tmuxctl attach-last
77
+ tmuxctl attach-last --by activity
78
+ tmuxctl attach-recent 2
79
+ tmuxctl attach-recent 3 --by activity
80
+ ```
81
+
82
+ `tmuxctl :codex` is shorthand for `tmuxctl create-or-attach codex`.
83
+
84
+ There are also short hidden aliases for the most recent sessions:
85
+
86
+ ```bash
87
+ tmuxctl a1
88
+ tmuxctl a2
89
+ tmuxctl a3
90
+ ```
91
+
92
+ Send one message now:
93
+
94
+ ```bash
95
+ tmuxctl send codex "check status and fix if something is broken or stuck"
96
+ ```
97
+
98
+ Send one message from a file:
99
+
100
+ ```bash
101
+ tmuxctl send rk-codex --message-file prompts/rk-codex-progress.txt
102
+ ```
103
+
104
+ By default, `tmuxctl send` waits `200ms` before pressing Return. Override that with `--enter-delay-ms` or disable Return with `--no-enter`.
105
+
106
+ Create a recurring job:
107
+
108
+ ```bash
109
+ tmuxctl add codex --every 15m --message "check status and fix if something is broken or stuck"
110
+ ```
111
+
112
+ Recurring jobs also store an Enter delay. By default that is `200ms`, and you can override it with `--enter-delay-ms`.
113
+
114
+ Example: send an automated follow-up to `rk-codex` every 30 minutes:
115
+
116
+ ```bash
117
+ tmuxctl add rk-codex --every 30m --message-file prompts/rk-codex-progress.txt
118
+ ```
119
+
120
+ You can load message text from a file with `--message-file` for `tmuxctl send`, `tmuxctl add`, and `tmuxctl edit`.
121
+
122
+ For `tmuxctl add` and `tmuxctl edit`, the file path is stored with the job. Scheduled runs read the file at send time, so updating the prompt file changes future runs without recreating the job.
123
+
124
+ To switch an existing job to the shared prompt file:
125
+
126
+ ```bash
127
+ tmuxctl jobs
128
+ tmuxctl edit <job_id> --message-file prompts/rk-codex-progress.txt
129
+ ```
130
+
131
+ Example: check a worker session every 30 minutes and unblock stalled progress:
132
+
133
+ ```bash
134
+ tmuxctl add lnewly-57 --every 30m --message "Status check for litehive: report current progress, current task, and the last meaningful change. Check whether progress is stalled, not whether you personally feel stuck. Identify blockers, lack of movement, repeated retries, failing commands, broken states, or missing dependencies. If progress is stalled, choose the best next concrete action to unblock litehive and execute it. Fix any problems you can fix now, then continue the work and summarize what changed."
135
+ ```
136
+
137
+ Edit an existing job:
138
+
139
+ ```bash
140
+ tmuxctl edit 2 --every 45m
141
+ tmuxctl edit 2 --message "check status and continue"
142
+ tmuxctl edit 3 --message-file prompts/rk-codex-progress.txt
143
+ ```
144
+
145
+ Remove a job:
146
+
147
+ ```bash
148
+ tmuxctl remove 3
149
+ ```
150
+
151
+ List jobs and logs:
152
+
153
+ ```bash
154
+ tmuxctl jobs
155
+ tmuxctl logs --limit 20
156
+ ```
157
+
158
+ `tmuxctl jobs` shows whether a job uses inline text or a linked file prompt.
159
+
160
+ Logs include the target session, whether the send was manual or scheduled, whether Return was sent, the Enter delay used, and any recorded error text.
161
+
162
+ If a scheduled job fails 3 runs in a row, `tmuxctl daemon` removes it automatically.
163
+
164
+ Run the scheduler:
165
+
166
+ ```bash
167
+ tmuxctl daemon
168
+ ```
169
+
170
+ ## Shortcuts
171
+
172
+ Useful shortcuts for hopping between recent sessions:
173
+
174
+ - `tmuxctl attach-last`
175
+ - `tmuxctl attach-recent 2`
176
+ - `tmuxctl attach-recent 3`
177
+ - `tmuxctl a1`
178
+ - `tmuxctl a2`
179
+ - `tmuxctl a3`
180
+
181
+ ## How Scheduling Works
182
+
183
+ `tmuxctl` does not create cron entries and it does not require editing `crontab`.
184
+
185
+ Recurring jobs are stored in SQLite at:
186
+
187
+ ```text
188
+ ~/.config/tmuxctl/tmuxctl.db
189
+ ```
190
+
191
+ The commands work like this:
192
+
193
+ - `tmuxctl add ...` inserts a recurring job into the database
194
+ - `tmuxctl edit`, `pause`, `resume`, and `remove` update that stored job
195
+ - `tmuxctl daemon` polls the database for due jobs and runs them
196
+
197
+ That means recurring sends only happen while the daemon is running.
198
+
199
+ If you want jobs to keep running after logout or reboot, use an external process manager to keep the daemon alive, for example:
200
+
201
+ - `systemd --user` on Linux
202
+ - `launchd` on macOS
203
+ - `cron @reboot` as a fallback
204
+
205
+ Even in those setups, cron or systemd only starts `tmuxctl daemon`. The recurring schedule itself still lives in the `tmuxctl` database.
206
+
207
+ ## Bash Completion
208
+
209
+ `tmuxctl` includes shell completion through Typer.
210
+
211
+ Install completion for your current Bash setup:
212
+
213
+ ```bash
214
+ tmuxctl --install-completion
215
+ ```
216
+
217
+ Preview or manually wire the Bash completion script:
218
+
219
+ ```bash
220
+ tmuxctl --show-completion bash
221
+ ```
222
+
223
+ Session-taking commands also complete existing tmux session names in Bash.
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "tmuxctl"
3
+ version = "0.1.1"
4
+ description = "Small tmux session controller with recurring sends"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ authors = [
8
+ { name = "Alexey Grigorev" },
9
+ ]
10
+ keywords = ["tmux", "cli", "scheduler", "automation"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: POSIX :: Linux",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Topic :: System :: Shells",
21
+ "Topic :: Utilities",
22
+ ]
23
+ dependencies = [
24
+ "typer>=0.12,<1",
25
+ ]
26
+
27
+ [project.scripts]
28
+ tmuxctl = "tmuxctl.cli:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/alexeygrigorev/tmuxctl"
32
+ Issues = "https://github.com/alexeygrigorev/tmuxctl/issues"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "hatch>=1,<2",
37
+ "pytest>=8,<9",
38
+ ]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+
43
+ [tool.setuptools.packages.find]
44
+ include = ["tmuxctl*"]
45
+
46
+ [build-system]
47
+ requires = ["setuptools>=68"]
48
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from typer.testing import CliRunner
7
+
8
+ from tmuxctl import cli
9
+ from tmuxctl.cli import app
10
+ from tmuxctl.models import Job
11
+
12
+
13
+ runner = CliRunner()
14
+
15
+
16
+ def test_send_reads_message_from_file(monkeypatch, tmp_path: Path) -> None:
17
+ message_file = tmp_path / "message.txt"
18
+ message_file.write_text("line 1\nline 2\n", encoding="utf-8")
19
+ captured: dict[str, object] = {}
20
+
21
+ monkeypatch.setattr("tmuxctl.cli._conn", lambda: object())
22
+ monkeypatch.setattr("tmuxctl.cli.tmux_api.session_exists", lambda session_name: True)
23
+
24
+ def fake_send_keys(session_name: str, message: str, press_enter: bool, enter_delay_ms: int) -> None:
25
+ captured["session_name"] = session_name
26
+ captured["message"] = message
27
+ captured["press_enter"] = press_enter
28
+ captured["enter_delay_ms"] = enter_delay_ms
29
+
30
+ monkeypatch.setattr("tmuxctl.cli.tmux_api.send_keys", fake_send_keys)
31
+ monkeypatch.setattr("tmuxctl.cli.storage.insert_log", lambda *args, **kwargs: None)
32
+
33
+ result = runner.invoke(app, ["send", "rk-codex", "--message-file", str(message_file)])
34
+
35
+ assert result.exit_code == 0
36
+ assert captured["session_name"] == "rk-codex"
37
+ assert captured["message"] == "line 1\nline 2"
38
+ assert captured["press_enter"] is True
39
+ assert captured["enter_delay_ms"] == 200
40
+
41
+
42
+ def test_add_rejects_both_message_and_message_file(monkeypatch, tmp_path: Path) -> None:
43
+ message_file = tmp_path / "message.txt"
44
+ message_file.write_text("hello", encoding="utf-8")
45
+
46
+ monkeypatch.setattr("tmuxctl.cli.tmux_api.session_exists", lambda session_name: True)
47
+
48
+ result = runner.invoke(
49
+ app,
50
+ [
51
+ "add",
52
+ "rk-codex",
53
+ "--every",
54
+ "30m",
55
+ "--message",
56
+ "hello",
57
+ "--message-file",
58
+ str(message_file),
59
+ ],
60
+ )
61
+
62
+ assert result.exit_code == 1
63
+ assert "choose either --message or --message-file, not both" in result.output
64
+
65
+
66
+ def test_add_stores_message_file_path(monkeypatch, tmp_path: Path) -> None:
67
+ message_file = tmp_path / "message.txt"
68
+ message_file.write_text("hello from file\n", encoding="utf-8")
69
+ captured: dict[str, object] = {}
70
+
71
+ monkeypatch.setattr("tmuxctl.cli.tmux_api.session_exists", lambda session_name: True)
72
+ monkeypatch.setattr("tmuxctl.cli._conn", lambda: object())
73
+ monkeypatch.setattr("tmuxctl.cli.parse_interval", lambda value: 1800)
74
+
75
+ class DummyJob:
76
+ id = 7
77
+ session_name = "rk-codex"
78
+ interval_seconds = 1800
79
+
80
+ def fake_create_job(conn, **kwargs):
81
+ captured.update(kwargs)
82
+ return DummyJob()
83
+
84
+ monkeypatch.setattr("tmuxctl.cli.storage.create_job", fake_create_job)
85
+
86
+ result = runner.invoke(
87
+ app,
88
+ ["add", "rk-codex", "--every", "30m", "--message-file", str(message_file)],
89
+ )
90
+
91
+ assert result.exit_code == 0
92
+ assert captured["message"] == "hello from file"
93
+ assert captured["message_file_path"] == str(message_file)
94
+
95
+
96
+ def test_jobs_shows_inline_and_file_sources(monkeypatch) -> None:
97
+ monkeypatch.setattr("tmuxctl.cli._conn", lambda: object())
98
+ monkeypatch.setattr(
99
+ "tmuxctl.cli.storage.list_jobs",
100
+ lambda conn: [
101
+ Job(
102
+ id=1,
103
+ session_name="inline",
104
+ message="short inline prompt",
105
+ message_file_path=None,
106
+ interval_seconds=900,
107
+ enabled=True,
108
+ send_enter=True,
109
+ enter_delay_ms=200,
110
+ created_at="2026-04-03T00:00:00+00:00",
111
+ updated_at="2026-04-03T00:00:00+00:00",
112
+ last_run_at=None,
113
+ next_run_at="2026-04-03T00:15:00+00:00",
114
+ ),
115
+ Job(
116
+ id=2,
117
+ session_name="linked",
118
+ message="stored snapshot",
119
+ message_file_path="prompts/rk-codex-progress.txt",
120
+ interval_seconds=1800,
121
+ enabled=True,
122
+ send_enter=True,
123
+ enter_delay_ms=200,
124
+ created_at="2026-04-03T00:00:00+00:00",
125
+ updated_at="2026-04-03T00:00:00+00:00",
126
+ last_run_at=None,
127
+ next_run_at="2026-04-03T00:30:00+00:00",
128
+ ),
129
+ ],
130
+ )
131
+
132
+ result = runner.invoke(app, ["jobs"])
133
+
134
+ assert result.exit_code == 0
135
+ assert "SOURCE" in result.output
136
+ assert "inline" in result.output
137
+ assert "file" in result.output
138
+ assert "short inline prompt" in result.output
139
+ assert "prompts/rk-codex-progress.txt" in result.output
140
+
141
+
142
+ def test_complete_session_names_filters_matches(monkeypatch) -> None:
143
+ monkeypatch.setattr("tmuxctl.cli.tmux_api.list_sessions", lambda: ["rk-codex", "rk-worker", "other"])
144
+
145
+ assert cli._complete_session_names("rk-") == ["rk-codex", "rk-worker"]
146
+
147
+
148
+ def test_main_rewrites_colon_shortcut(monkeypatch) -> None:
149
+ captured: dict[str, object] = {}
150
+
151
+ def fake_app(*, args):
152
+ captured["args"] = args
153
+
154
+ monkeypatch.setattr(cli, "app", fake_app)
155
+ monkeypatch.setattr(sys, "argv", ["tmuxctl", ":rk-codex"])
156
+
157
+ cli.main()
158
+
159
+ assert captured["args"] == ["create-or-attach", "rk-codex"]