tsugite-tmux 0.14.0__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.
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# ---> Python
|
|
2
|
+
# Byte-compiled / optimized / DLL files
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.py[cod]
|
|
5
|
+
*$py.class
|
|
6
|
+
|
|
7
|
+
# C extensions
|
|
8
|
+
*.so
|
|
9
|
+
|
|
10
|
+
# Distribution / packaging
|
|
11
|
+
.Python
|
|
12
|
+
build/
|
|
13
|
+
develop-eggs/
|
|
14
|
+
dist/
|
|
15
|
+
downloads/
|
|
16
|
+
eggs/
|
|
17
|
+
.eggs/
|
|
18
|
+
lib/
|
|
19
|
+
lib64/
|
|
20
|
+
parts/
|
|
21
|
+
sdist/
|
|
22
|
+
var/
|
|
23
|
+
wheels/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py,cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
cover/
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Django stuff:
|
|
60
|
+
*.log
|
|
61
|
+
local_settings.py
|
|
62
|
+
db.sqlite3
|
|
63
|
+
db.sqlite3-journal
|
|
64
|
+
|
|
65
|
+
# Flask stuff:
|
|
66
|
+
instance/
|
|
67
|
+
.webassets-cache
|
|
68
|
+
|
|
69
|
+
# Scrapy stuff:
|
|
70
|
+
.scrapy
|
|
71
|
+
|
|
72
|
+
# Sphinx documentation
|
|
73
|
+
docs/_build/
|
|
74
|
+
|
|
75
|
+
# PyBuilder
|
|
76
|
+
.pybuilder/
|
|
77
|
+
target/
|
|
78
|
+
|
|
79
|
+
# Jupyter Notebook
|
|
80
|
+
.ipynb_checkpoints
|
|
81
|
+
|
|
82
|
+
# IPython
|
|
83
|
+
profile_default/
|
|
84
|
+
ipython_config.py
|
|
85
|
+
|
|
86
|
+
# pyenv
|
|
87
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
88
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
89
|
+
# .python-version
|
|
90
|
+
|
|
91
|
+
# pipenv
|
|
92
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
93
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
94
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
95
|
+
# install all needed dependencies.
|
|
96
|
+
#Pipfile.lock
|
|
97
|
+
|
|
98
|
+
# poetry
|
|
99
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
100
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
101
|
+
# commonly ignored for libraries.
|
|
102
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
103
|
+
#poetry.lock
|
|
104
|
+
|
|
105
|
+
# pdm
|
|
106
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
107
|
+
#pdm.lock
|
|
108
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
109
|
+
# in version control.
|
|
110
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
111
|
+
.pdm.toml
|
|
112
|
+
.pdm-python
|
|
113
|
+
.pdm-build/
|
|
114
|
+
|
|
115
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
116
|
+
__pypackages__/
|
|
117
|
+
|
|
118
|
+
# Celery stuff
|
|
119
|
+
celerybeat-schedule
|
|
120
|
+
celerybeat.pid
|
|
121
|
+
|
|
122
|
+
# SageMath parsed files
|
|
123
|
+
*.sage.py
|
|
124
|
+
|
|
125
|
+
# Environments
|
|
126
|
+
.env
|
|
127
|
+
.venv
|
|
128
|
+
env/
|
|
129
|
+
venv/
|
|
130
|
+
ENV/
|
|
131
|
+
env.bak/
|
|
132
|
+
venv.bak/
|
|
133
|
+
|
|
134
|
+
# Spyder project settings
|
|
135
|
+
.spyderproject
|
|
136
|
+
.spyproject
|
|
137
|
+
|
|
138
|
+
# Rope project settings
|
|
139
|
+
.ropeproject
|
|
140
|
+
|
|
141
|
+
# mkdocs documentation
|
|
142
|
+
/site
|
|
143
|
+
|
|
144
|
+
# mypy
|
|
145
|
+
.mypy_cache/
|
|
146
|
+
.dmypy.json
|
|
147
|
+
dmypy.json
|
|
148
|
+
|
|
149
|
+
# Pyre type checker
|
|
150
|
+
.pyre/
|
|
151
|
+
|
|
152
|
+
# pytype static type analyzer
|
|
153
|
+
.pytype/
|
|
154
|
+
|
|
155
|
+
# Cython debug symbols
|
|
156
|
+
cython_debug/
|
|
157
|
+
|
|
158
|
+
# PyCharm
|
|
159
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
160
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
161
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
162
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
163
|
+
#.idea/
|
|
164
|
+
|
|
165
|
+
.env
|
|
166
|
+
.env
|
|
167
|
+
benchmark_results/
|
|
168
|
+
test_output/
|
|
169
|
+
.claude/settings.local.json
|
|
170
|
+
std*.txt
|
|
171
|
+
secrets/*
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# TODO: temp - I need to clean up the docs
|
|
175
|
+
docs-old/
|
|
176
|
+
examples/*
|
|
177
|
+
!examples/tsugite-example-plugin/
|
|
178
|
+
agents/
|
|
179
|
+
.claude/
|
|
180
|
+
.tsugite/
|
|
181
|
+
benchmarks/
|
|
182
|
+
docker-compose.test.yml
|
|
183
|
+
#### TODO ^^^
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tsugite-tmux"
|
|
3
|
+
version = "0.14.0"
|
|
4
|
+
description = "Tsugite plugin: tmux session management tools with automatic output logging"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = ["tsugite-cli==0.14.0"]
|
|
7
|
+
|
|
8
|
+
[project.entry-points."tsugite.plugins"]
|
|
9
|
+
tmux = "tsugite_tmux"
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["hatchling"]
|
|
13
|
+
build-backend = "hatchling.build"
|
|
14
|
+
|
|
15
|
+
[tool.uv.sources]
|
|
16
|
+
tsugite-cli = { workspace = true }
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Tests for tmux session management tools."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from unittest.mock import MagicMock, call, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from tsugite_tmux import (
|
|
10
|
+
_list_managed_sessions,
|
|
11
|
+
_strip_ansi,
|
|
12
|
+
_validate_name,
|
|
13
|
+
get_tmux_sessions,
|
|
14
|
+
tmux_create,
|
|
15
|
+
tmux_kill,
|
|
16
|
+
tmux_list,
|
|
17
|
+
tmux_read,
|
|
18
|
+
tmux_send,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def mock_metadata(tmp_path, monkeypatch):
|
|
24
|
+
"""Redirect metadata and log paths to tmp_path."""
|
|
25
|
+
meta_dir = tmp_path / "tmux"
|
|
26
|
+
log_dir = tmp_path / "tmux-logs"
|
|
27
|
+
meta_dir.mkdir()
|
|
28
|
+
log_dir.mkdir()
|
|
29
|
+
|
|
30
|
+
monkeypatch.setattr("tsugite_tmux._get_metadata_path", lambda: meta_dir / "sessions.json")
|
|
31
|
+
monkeypatch.setattr("tsugite_tmux._get_log_dir", lambda: log_dir)
|
|
32
|
+
return tmp_path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _make_run_result(returncode=0, stdout="", stderr=""):
|
|
36
|
+
result = MagicMock(spec=subprocess.CompletedProcess)
|
|
37
|
+
result.returncode = returncode
|
|
38
|
+
result.stdout = stdout
|
|
39
|
+
result.stderr = stderr
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestStripAnsi:
|
|
44
|
+
def test_strips_sgr_sequences(self):
|
|
45
|
+
assert _strip_ansi("\x1b[31mred\x1b[0m") == "red"
|
|
46
|
+
|
|
47
|
+
def test_strips_bold_and_color(self):
|
|
48
|
+
assert _strip_ansi("\x1b[1;32mgreen bold\x1b[0m") == "green bold"
|
|
49
|
+
|
|
50
|
+
def test_strips_osc_sequences(self):
|
|
51
|
+
assert _strip_ansi("\x1b]0;title\x07text") == "text"
|
|
52
|
+
|
|
53
|
+
def test_strips_charset_designator(self):
|
|
54
|
+
assert _strip_ansi("\x1b(Btext") == "text"
|
|
55
|
+
|
|
56
|
+
def test_passthrough_clean_text(self):
|
|
57
|
+
assert _strip_ansi("hello world") == "hello world"
|
|
58
|
+
|
|
59
|
+
def test_mixed_ansi(self):
|
|
60
|
+
text = "\x1b[1m\x1b[32mOK\x1b[0m: \x1b[34mtest\x1b[0m passed"
|
|
61
|
+
assert _strip_ansi(text) == "OK: test passed"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestValidateName:
|
|
65
|
+
def test_valid_names(self):
|
|
66
|
+
for name in ["test", "my-session", "project_1", "A-b_C-3"]:
|
|
67
|
+
_validate_name(name)
|
|
68
|
+
|
|
69
|
+
def test_invalid_names(self):
|
|
70
|
+
for name in ["has space", "semi;colon", "pipe|char", "slash/path", ""]:
|
|
71
|
+
with pytest.raises(ValueError, match="Invalid session name"):
|
|
72
|
+
_validate_name(name)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestTmuxCreate:
|
|
76
|
+
@patch("tsugite_tmux._session_exists", return_value=False)
|
|
77
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
78
|
+
def test_create_session(self, mock_run, mock_exists, mock_metadata):
|
|
79
|
+
mock_run.return_value = _make_run_result()
|
|
80
|
+
|
|
81
|
+
result = tmux_create("test")
|
|
82
|
+
|
|
83
|
+
assert result["name"] == "test"
|
|
84
|
+
assert result["tmux_session"] == "tsu-test"
|
|
85
|
+
assert result["status"] == "created"
|
|
86
|
+
assert "log_file" in result
|
|
87
|
+
|
|
88
|
+
calls = mock_run.call_args_list
|
|
89
|
+
assert calls[0] == call(
|
|
90
|
+
["tmux", "new-session", "-d", "-s", "tsu-test", "-x", "200", "-y", "50"],
|
|
91
|
+
capture_output=True,
|
|
92
|
+
text=True,
|
|
93
|
+
)
|
|
94
|
+
assert calls[1].args[0][:4] == ["tmux", "pipe-pane", "-t", "tsu-test"]
|
|
95
|
+
|
|
96
|
+
@patch("tsugite_tmux._session_exists", return_value=False)
|
|
97
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
98
|
+
def test_create_with_command(self, mock_run, mock_exists, mock_metadata):
|
|
99
|
+
mock_run.return_value = _make_run_result()
|
|
100
|
+
|
|
101
|
+
tmux_create("test", command="htop")
|
|
102
|
+
|
|
103
|
+
new_session_call = mock_run.call_args_list[0]
|
|
104
|
+
assert "htop" in new_session_call.args[0]
|
|
105
|
+
|
|
106
|
+
@patch("tsugite_tmux._session_exists", return_value=True)
|
|
107
|
+
def test_create_already_exists(self, mock_exists):
|
|
108
|
+
with pytest.raises(RuntimeError, match="already exists"):
|
|
109
|
+
tmux_create("test")
|
|
110
|
+
|
|
111
|
+
def test_create_invalid_name(self):
|
|
112
|
+
with pytest.raises(ValueError, match="Invalid session name"):
|
|
113
|
+
tmux_create("bad name")
|
|
114
|
+
|
|
115
|
+
@patch("tsugite_tmux._session_exists", return_value=False)
|
|
116
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
117
|
+
def test_create_saves_metadata(self, mock_run, mock_exists, mock_metadata):
|
|
118
|
+
mock_run.return_value = _make_run_result()
|
|
119
|
+
|
|
120
|
+
tmux_create("myproject", command="python3")
|
|
121
|
+
|
|
122
|
+
meta_path = mock_metadata / "tmux" / "sessions.json"
|
|
123
|
+
assert meta_path.exists()
|
|
124
|
+
data = json.loads(meta_path.read_text())
|
|
125
|
+
assert "myproject" in data
|
|
126
|
+
assert data["myproject"]["command"] == "python3"
|
|
127
|
+
assert data["myproject"]["prefixed_name"] == "tsu-myproject"
|
|
128
|
+
|
|
129
|
+
@patch("tsugite_tmux._session_exists", return_value=False)
|
|
130
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
131
|
+
def test_create_cleans_up_on_pipe_failure(self, mock_run, mock_exists, mock_metadata):
|
|
132
|
+
mock_run.side_effect = [
|
|
133
|
+
_make_run_result(), # new-session succeeds
|
|
134
|
+
_make_run_result(returncode=1, stderr="pipe error"), # pipe-pane fails
|
|
135
|
+
_make_run_result(), # kill-session cleanup
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
with pytest.raises(RuntimeError, match="Failed to set up logging"):
|
|
139
|
+
tmux_create("test")
|
|
140
|
+
|
|
141
|
+
@patch("tsugite_tmux._session_exists", return_value=False)
|
|
142
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
143
|
+
def test_create_pipe_pane_uses_shlex_quote(self, mock_run, mock_exists, mock_metadata):
|
|
144
|
+
mock_run.return_value = _make_run_result()
|
|
145
|
+
|
|
146
|
+
tmux_create("test")
|
|
147
|
+
|
|
148
|
+
pipe_call = mock_run.call_args_list[1]
|
|
149
|
+
pipe_arg = pipe_call.args[0][5] # The -o argument value
|
|
150
|
+
assert pipe_arg.startswith("cat >> ")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestTmuxRead:
|
|
154
|
+
@patch("tsugite_tmux._session_exists", return_value=True)
|
|
155
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
156
|
+
def test_read_pane(self, mock_run, mock_exists):
|
|
157
|
+
mock_run.return_value = _make_run_result(stdout="\x1b[32mhello\x1b[0m world\n")
|
|
158
|
+
|
|
159
|
+
result = tmux_read("test", lines=10)
|
|
160
|
+
|
|
161
|
+
assert result == "hello world\n"
|
|
162
|
+
mock_run.assert_called_once_with(
|
|
163
|
+
["tmux", "capture-pane", "-t", "tsu-test", "-p", "-S", "-10"],
|
|
164
|
+
capture_output=True,
|
|
165
|
+
text=True,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def test_read_log(self, mock_metadata):
|
|
169
|
+
log_dir = mock_metadata / "tmux-logs"
|
|
170
|
+
log_file = log_dir / "test.log"
|
|
171
|
+
log_file.write_text("line1\nline2\nline3\n\x1b[31mline4\x1b[0m\n")
|
|
172
|
+
|
|
173
|
+
result = tmux_read("test", lines=2, source="log")
|
|
174
|
+
|
|
175
|
+
assert result == "line3\nline4\n"
|
|
176
|
+
|
|
177
|
+
@patch("tsugite_tmux._session_exists", return_value=False)
|
|
178
|
+
def test_read_nonexistent_pane(self, mock_exists):
|
|
179
|
+
with pytest.raises(RuntimeError, match="not found"):
|
|
180
|
+
tmux_read("nonexistent")
|
|
181
|
+
|
|
182
|
+
def test_read_nonexistent_log(self, mock_metadata):
|
|
183
|
+
with pytest.raises(RuntimeError, match="No log file"):
|
|
184
|
+
tmux_read("nonexistent", source="log")
|
|
185
|
+
|
|
186
|
+
def test_read_invalid_source(self):
|
|
187
|
+
with pytest.raises(ValueError, match="Invalid source"):
|
|
188
|
+
tmux_read("test", source="invalid")
|
|
189
|
+
|
|
190
|
+
@patch("tsugite_tmux._session_exists", return_value=True)
|
|
191
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
192
|
+
def test_read_clamps_lines(self, mock_run, mock_exists):
|
|
193
|
+
mock_run.return_value = _make_run_result(stdout="text\n")
|
|
194
|
+
|
|
195
|
+
tmux_read("test", lines=99999)
|
|
196
|
+
|
|
197
|
+
args = mock_run.call_args.args[0]
|
|
198
|
+
assert "-5000" in args
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class TestTmuxSend:
|
|
202
|
+
@patch("tsugite_tmux._session_exists", return_value=True)
|
|
203
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
204
|
+
def test_send_with_enter(self, mock_run, mock_exists):
|
|
205
|
+
mock_run.return_value = _make_run_result()
|
|
206
|
+
|
|
207
|
+
result = tmux_send("test", "ls -la")
|
|
208
|
+
|
|
209
|
+
mock_run.assert_called_once_with(
|
|
210
|
+
["tmux", "send-keys", "-t", "tsu-test", "ls -la", "Enter"],
|
|
211
|
+
capture_output=True,
|
|
212
|
+
text=True,
|
|
213
|
+
)
|
|
214
|
+
assert "command" in result
|
|
215
|
+
|
|
216
|
+
@patch("tsugite_tmux._session_exists", return_value=True)
|
|
217
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
218
|
+
def test_send_without_enter(self, mock_run, mock_exists):
|
|
219
|
+
mock_run.return_value = _make_run_result()
|
|
220
|
+
|
|
221
|
+
result = tmux_send("test", "q", enter=False)
|
|
222
|
+
|
|
223
|
+
mock_run.assert_called_once_with(
|
|
224
|
+
["tmux", "send-keys", "-t", "tsu-test", "q"],
|
|
225
|
+
capture_output=True,
|
|
226
|
+
text=True,
|
|
227
|
+
)
|
|
228
|
+
assert "keys" in result
|
|
229
|
+
|
|
230
|
+
@patch("tsugite_tmux._session_exists", return_value=False)
|
|
231
|
+
def test_send_nonexistent(self, mock_exists):
|
|
232
|
+
with pytest.raises(RuntimeError, match="not found"):
|
|
233
|
+
tmux_send("nonexistent", "hello")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class TestListManagedSessions:
|
|
237
|
+
"""Tests for the shared _list_managed_sessions helper used by tmux_list and get_tmux_sessions."""
|
|
238
|
+
|
|
239
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
240
|
+
def test_filters_by_prefix(self, mock_run, mock_metadata):
|
|
241
|
+
mock_run.return_value = _make_run_result(
|
|
242
|
+
stdout="tsu-project1\tbash\nuser-session\tvim\ntsu-project2\tpython3\n"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
meta_path = mock_metadata / "tmux" / "sessions.json"
|
|
246
|
+
meta_path.write_text(
|
|
247
|
+
json.dumps(
|
|
248
|
+
{
|
|
249
|
+
"project1": {"command": "htop", "created_at": "2026-01-01T00:00:00", "log_file": "/tmp/p1.log"},
|
|
250
|
+
"project2": {"command": None, "created_at": "2026-01-02T00:00:00", "log_file": "/tmp/p2.log"},
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
result = _list_managed_sessions()
|
|
256
|
+
|
|
257
|
+
assert len(result) == 2
|
|
258
|
+
names = [s["name"] for s in result]
|
|
259
|
+
assert "project1" in names
|
|
260
|
+
assert "project2" in names
|
|
261
|
+
|
|
262
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
263
|
+
def test_idle_status_for_shell(self, mock_run, mock_metadata):
|
|
264
|
+
mock_run.return_value = _make_run_result(stdout="tsu-test\tbash\n")
|
|
265
|
+
|
|
266
|
+
result = _list_managed_sessions()
|
|
267
|
+
|
|
268
|
+
assert result[0]["status"] == "idle"
|
|
269
|
+
|
|
270
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
271
|
+
def test_active_status_for_process(self, mock_run, mock_metadata):
|
|
272
|
+
mock_run.return_value = _make_run_result(stdout="tsu-test\tpython3\n")
|
|
273
|
+
|
|
274
|
+
result = _list_managed_sessions()
|
|
275
|
+
|
|
276
|
+
assert result[0]["status"] == "active: python3"
|
|
277
|
+
|
|
278
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
279
|
+
def test_no_server(self, mock_run):
|
|
280
|
+
mock_run.return_value = _make_run_result(returncode=1, stderr="no server running")
|
|
281
|
+
|
|
282
|
+
assert _list_managed_sessions() == []
|
|
283
|
+
|
|
284
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
285
|
+
def test_no_managed_sessions(self, mock_run, mock_metadata):
|
|
286
|
+
mock_run.return_value = _make_run_result(stdout="user-session\tbash\n")
|
|
287
|
+
|
|
288
|
+
assert _list_managed_sessions() == []
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class TestTmuxList:
|
|
292
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
293
|
+
def test_list_delegates_to_shared_helper(self, mock_run, mock_metadata):
|
|
294
|
+
mock_run.return_value = _make_run_result(stdout="tsu-project1\tbash\ntsu-project2\thtop\n")
|
|
295
|
+
|
|
296
|
+
result = tmux_list()
|
|
297
|
+
|
|
298
|
+
assert len(result) == 2
|
|
299
|
+
assert result[0]["status"] == "idle"
|
|
300
|
+
assert result[1]["status"] == "active: htop"
|
|
301
|
+
|
|
302
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
303
|
+
def test_list_no_server(self, mock_run):
|
|
304
|
+
mock_run.return_value = _make_run_result(returncode=1)
|
|
305
|
+
|
|
306
|
+
assert tmux_list() == []
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class TestTmuxKill:
|
|
310
|
+
@patch("tsugite_tmux._session_exists", return_value=True)
|
|
311
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
312
|
+
def test_kill_session(self, mock_run, mock_exists, mock_metadata):
|
|
313
|
+
mock_run.return_value = _make_run_result()
|
|
314
|
+
|
|
315
|
+
meta_path = mock_metadata / "tmux" / "sessions.json"
|
|
316
|
+
meta_path.write_text(json.dumps({"test": {"command": "htop"}}))
|
|
317
|
+
|
|
318
|
+
result = tmux_kill("test")
|
|
319
|
+
|
|
320
|
+
mock_run.assert_called_once_with(
|
|
321
|
+
["tmux", "kill-session", "-t", "tsu-test"],
|
|
322
|
+
capture_output=True,
|
|
323
|
+
text=True,
|
|
324
|
+
)
|
|
325
|
+
assert "terminated" in result
|
|
326
|
+
|
|
327
|
+
data = json.loads(meta_path.read_text())
|
|
328
|
+
assert "test" not in data
|
|
329
|
+
|
|
330
|
+
@patch("tsugite_tmux._session_exists", return_value=False)
|
|
331
|
+
def test_kill_nonexistent(self, mock_exists):
|
|
332
|
+
with pytest.raises(RuntimeError, match="not found"):
|
|
333
|
+
tmux_kill("nonexistent")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class TestGetTmuxSessions:
|
|
337
|
+
@patch("tsugite_tmux.shutil.which", return_value=None)
|
|
338
|
+
def test_no_tmux_installed(self, mock_which):
|
|
339
|
+
assert get_tmux_sessions() == []
|
|
340
|
+
|
|
341
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
342
|
+
@patch("tsugite_tmux.shutil.which", return_value="/usr/bin/tmux")
|
|
343
|
+
def test_filters_prefix(self, mock_which, mock_run, mock_metadata):
|
|
344
|
+
mock_run.return_value = _make_run_result(stdout="tsu-myproject\tbash\nother-session\tvim\n")
|
|
345
|
+
|
|
346
|
+
result = get_tmux_sessions()
|
|
347
|
+
|
|
348
|
+
assert len(result) == 1
|
|
349
|
+
assert result[0]["name"] == "myproject"
|
|
350
|
+
assert "created_at" not in result[0]
|
|
351
|
+
assert "log_file" not in result[0]
|
|
352
|
+
|
|
353
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
354
|
+
@patch("tsugite_tmux.shutil.which", return_value="/usr/bin/tmux")
|
|
355
|
+
def test_idle_status(self, mock_which, mock_run, mock_metadata):
|
|
356
|
+
mock_run.return_value = _make_run_result(stdout="tsu-test\tzsh\n")
|
|
357
|
+
|
|
358
|
+
result = get_tmux_sessions()
|
|
359
|
+
|
|
360
|
+
assert result[0]["status"] == "idle"
|
|
361
|
+
|
|
362
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
363
|
+
@patch("tsugite_tmux.shutil.which", return_value="/usr/bin/tmux")
|
|
364
|
+
def test_active_status(self, mock_which, mock_run, mock_metadata):
|
|
365
|
+
mock_run.return_value = _make_run_result(stdout="tsu-test\thtop\n")
|
|
366
|
+
|
|
367
|
+
result = get_tmux_sessions()
|
|
368
|
+
|
|
369
|
+
assert result[0]["status"] == "active: htop"
|
|
370
|
+
|
|
371
|
+
@patch("tsugite_tmux.subprocess.run")
|
|
372
|
+
@patch("tsugite_tmux.shutil.which", return_value="/usr/bin/tmux")
|
|
373
|
+
def test_no_server_running(self, mock_which, mock_run):
|
|
374
|
+
mock_run.return_value = _make_run_result(returncode=1)
|
|
375
|
+
|
|
376
|
+
assert get_tmux_sessions() == []
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Tsugite plugin: tmux session management tools with automatic output logging."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections import deque
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from tsugite.cli.helpers import get_workspace_dir
|
|
15
|
+
from tsugite.config import get_xdg_data_path
|
|
16
|
+
from tsugite.tools import tool
|
|
17
|
+
|
|
18
|
+
SESSION_PREFIX = "tsu-"
|
|
19
|
+
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b\(B")
|
|
20
|
+
SHELLS = {"bash", "zsh", "sh", "fish", "dash", "ksh", "csh", "tcsh"}
|
|
21
|
+
VALID_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _prefixed(name: str) -> str:
|
|
25
|
+
return f"{SESSION_PREFIX}{name}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _strip_ansi(text: str) -> str:
|
|
29
|
+
return ANSI_RE.sub("", text)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validate_name(name: str) -> None:
|
|
33
|
+
if not VALID_NAME_RE.match(name):
|
|
34
|
+
raise ValueError(f"Invalid session name '{name}': only alphanumeric, hyphens, and underscores allowed")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _session_exists(prefixed_name: str) -> bool:
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
["tmux", "has-session", "-t", prefixed_name],
|
|
40
|
+
capture_output=True,
|
|
41
|
+
)
|
|
42
|
+
return result.returncode == 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_metadata_path() -> Path:
|
|
46
|
+
return get_xdg_data_path("tmux") / "sessions.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_metadata() -> dict:
|
|
50
|
+
path = _get_metadata_path()
|
|
51
|
+
if not path.exists():
|
|
52
|
+
return {}
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(path.read_text())
|
|
55
|
+
except (json.JSONDecodeError, OSError):
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _save_metadata(data: dict) -> None:
|
|
60
|
+
path = _get_metadata_path()
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
tmp = path.with_suffix(".tmp")
|
|
63
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
64
|
+
os.replace(str(tmp), str(path))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _get_log_dir() -> Path:
|
|
68
|
+
return get_xdg_data_path("tmux-logs")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _list_managed_sessions() -> list:
|
|
72
|
+
"""Fetch all managed sessions with status in a single batched subprocess call."""
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
["tmux", "list-panes", "-a", "-F", "#{session_name}\t#{pane_current_command}"],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
)
|
|
78
|
+
if result.returncode != 0:
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
pane_cmds = {}
|
|
82
|
+
for line in result.stdout.strip().split("\n"):
|
|
83
|
+
if not line.strip():
|
|
84
|
+
continue
|
|
85
|
+
parts = line.split("\t", 1)
|
|
86
|
+
session_name = parts[0]
|
|
87
|
+
if session_name.startswith(SESSION_PREFIX) and session_name not in pane_cmds:
|
|
88
|
+
pane_cmds[session_name] = parts[1] if len(parts) > 1 else ""
|
|
89
|
+
|
|
90
|
+
if not pane_cmds:
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
metadata = _load_metadata()
|
|
94
|
+
sessions = []
|
|
95
|
+
for prefixed, cmd in pane_cmds.items():
|
|
96
|
+
name = prefixed[len(SESSION_PREFIX) :]
|
|
97
|
+
status = "idle" if (not cmd or cmd.lower() in SHELLS) else f"active: {cmd}"
|
|
98
|
+
meta = metadata.get(name, {})
|
|
99
|
+
sessions.append(
|
|
100
|
+
{
|
|
101
|
+
"name": name,
|
|
102
|
+
"status": status,
|
|
103
|
+
"created_at": meta.get("created_at", "unknown"),
|
|
104
|
+
"command": meta.get("command"),
|
|
105
|
+
"log_file": meta.get("log_file"),
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return sessions
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@tool(category="tmux")
|
|
113
|
+
def tmux_create(name: str, command: Optional[str] = None) -> dict:
|
|
114
|
+
"""Create a named tmux session with automatic output logging.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
name: Session name (alphanumeric, hyphens, underscores)
|
|
118
|
+
command: Initial command to run in the session (e.g., "htop", "python3")
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Dict with session name, tmux session name, log file path, and status
|
|
122
|
+
"""
|
|
123
|
+
_validate_name(name)
|
|
124
|
+
prefixed = _prefixed(name)
|
|
125
|
+
|
|
126
|
+
if _session_exists(prefixed):
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
f"Session '{name}' already exists. Kill it first with tmux_kill('{name}') or use a different name."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
log_dir = _get_log_dir()
|
|
132
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
log_file = log_dir / f"{name}.log"
|
|
134
|
+
|
|
135
|
+
workspace = get_workspace_dir()
|
|
136
|
+
cmd = ["tmux", "new-session", "-d", "-s", prefixed, "-x", "200", "-y", "50"]
|
|
137
|
+
if workspace is not None:
|
|
138
|
+
cmd.extend(["-c", str(workspace)])
|
|
139
|
+
if command:
|
|
140
|
+
cmd.append(command)
|
|
141
|
+
|
|
142
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
143
|
+
if result.returncode != 0:
|
|
144
|
+
raise RuntimeError(f"Failed to create tmux session: {result.stderr.strip()}")
|
|
145
|
+
|
|
146
|
+
pipe_result = subprocess.run(
|
|
147
|
+
["tmux", "pipe-pane", "-t", prefixed, "-o", f"cat >> {shlex.quote(str(log_file))}"],
|
|
148
|
+
capture_output=True,
|
|
149
|
+
text=True,
|
|
150
|
+
)
|
|
151
|
+
if pipe_result.returncode != 0:
|
|
152
|
+
subprocess.run(["tmux", "kill-session", "-t", prefixed], capture_output=True)
|
|
153
|
+
raise RuntimeError(f"Failed to set up logging: {pipe_result.stderr.strip()}")
|
|
154
|
+
|
|
155
|
+
metadata = _load_metadata()
|
|
156
|
+
metadata[name] = {
|
|
157
|
+
"prefixed_name": prefixed,
|
|
158
|
+
"log_file": str(log_file),
|
|
159
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
160
|
+
"command": command,
|
|
161
|
+
}
|
|
162
|
+
_save_metadata(metadata)
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
"name": name,
|
|
166
|
+
"tmux_session": prefixed,
|
|
167
|
+
"log_file": str(log_file),
|
|
168
|
+
"status": "created",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@tool(category="tmux")
|
|
173
|
+
def tmux_read(name: str, lines: int = 50, source: str = "pane") -> str:
|
|
174
|
+
"""Read output from a tmux session.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
name: Session name (as given to tmux_create)
|
|
178
|
+
lines: Number of lines to capture (default: 50, max: 5000)
|
|
179
|
+
source: "pane" for current visible content, "log" for full pipe-pane log history
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Session output with ANSI escape codes stripped
|
|
183
|
+
"""
|
|
184
|
+
_validate_name(name)
|
|
185
|
+
prefixed = _prefixed(name)
|
|
186
|
+
lines = max(1, min(lines, 5000))
|
|
187
|
+
|
|
188
|
+
if source == "pane":
|
|
189
|
+
if not _session_exists(prefixed):
|
|
190
|
+
raise RuntimeError(f"Session '{name}' not found. Use tmux_list() to see active sessions.")
|
|
191
|
+
|
|
192
|
+
result = subprocess.run(
|
|
193
|
+
["tmux", "capture-pane", "-t", prefixed, "-p", "-S", f"-{lines}"],
|
|
194
|
+
capture_output=True,
|
|
195
|
+
text=True,
|
|
196
|
+
)
|
|
197
|
+
if result.returncode != 0:
|
|
198
|
+
raise RuntimeError(f"Failed to capture pane: {result.stderr.strip()}")
|
|
199
|
+
return _strip_ansi(result.stdout)
|
|
200
|
+
|
|
201
|
+
elif source == "log":
|
|
202
|
+
log_file = _get_log_dir() / f"{name}.log"
|
|
203
|
+
if not log_file.exists():
|
|
204
|
+
raise RuntimeError(f"No log file found for session '{name}'.")
|
|
205
|
+
with open(log_file) as f:
|
|
206
|
+
tail = deque(f, maxlen=lines)
|
|
207
|
+
return _strip_ansi("".join(tail))
|
|
208
|
+
|
|
209
|
+
else:
|
|
210
|
+
raise ValueError(f"Invalid source '{source}': must be 'pane' or 'log'")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@tool(category="tmux")
|
|
214
|
+
def tmux_send(name: str, keys: str, enter: bool = True) -> str:
|
|
215
|
+
"""Send keystrokes to a tmux session.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
name: Session name (as given to tmux_create)
|
|
219
|
+
keys: Keys to send (text command or tmux key names like "C-c", "Enter", "q")
|
|
220
|
+
enter: Whether to send Enter after the keys (default: True). Set False for
|
|
221
|
+
single-key interactive inputs like "q" to quit, "y" to confirm, etc.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Confirmation message
|
|
225
|
+
"""
|
|
226
|
+
_validate_name(name)
|
|
227
|
+
prefixed = _prefixed(name)
|
|
228
|
+
|
|
229
|
+
if not _session_exists(prefixed):
|
|
230
|
+
raise RuntimeError(f"Session '{name}' not found. Use tmux_list() to see active sessions.")
|
|
231
|
+
|
|
232
|
+
cmd = ["tmux", "send-keys", "-t", prefixed, keys]
|
|
233
|
+
if enter:
|
|
234
|
+
cmd.append("Enter")
|
|
235
|
+
|
|
236
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
237
|
+
if result.returncode != 0:
|
|
238
|
+
raise RuntimeError(f"Failed to send keys: {result.stderr.strip()}")
|
|
239
|
+
|
|
240
|
+
return f"Sent {'keys' if not enter else 'command'} to session '{name}'"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@tool(category="tmux")
|
|
244
|
+
def tmux_list() -> list:
|
|
245
|
+
"""List all tsugite-managed tmux sessions with their current status.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
List of dicts with name, status, created_at, command, and log_file for each session
|
|
249
|
+
"""
|
|
250
|
+
return _list_managed_sessions()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@tool(category="tmux")
|
|
254
|
+
def tmux_kill(name: str) -> str:
|
|
255
|
+
"""Terminate a tmux session and clean up its metadata.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
name: Session name (as given to tmux_create)
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Confirmation message
|
|
262
|
+
"""
|
|
263
|
+
_validate_name(name)
|
|
264
|
+
prefixed = _prefixed(name)
|
|
265
|
+
|
|
266
|
+
if not _session_exists(prefixed):
|
|
267
|
+
raise RuntimeError(f"Session '{name}' not found. Use tmux_list() to see active sessions.")
|
|
268
|
+
|
|
269
|
+
result = subprocess.run(
|
|
270
|
+
["tmux", "kill-session", "-t", prefixed],
|
|
271
|
+
capture_output=True,
|
|
272
|
+
text=True,
|
|
273
|
+
)
|
|
274
|
+
if result.returncode != 0:
|
|
275
|
+
raise RuntimeError(f"Failed to kill session: {result.stderr.strip()}")
|
|
276
|
+
|
|
277
|
+
metadata = _load_metadata()
|
|
278
|
+
metadata.pop(name, None)
|
|
279
|
+
_save_metadata(metadata)
|
|
280
|
+
|
|
281
|
+
return f"Session '{name}' terminated. Log file preserved at {_get_log_dir() / f'{name}.log'}"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def get_tmux_sessions() -> list:
|
|
285
|
+
"""Active tsugite-managed tmux sessions, for use as a Jinja2 template global.
|
|
286
|
+
|
|
287
|
+
Returns a list of dicts with name, status, and command for each session.
|
|
288
|
+
Returns empty list if tmux is not installed or no managed sessions exist.
|
|
289
|
+
"""
|
|
290
|
+
if not shutil.which("tmux"):
|
|
291
|
+
return []
|
|
292
|
+
return [{"name": s["name"], "status": s["status"], "command": s["command"]} for s in _list_managed_sessions()]
|