cleat 0.1.0__py3-none-any.whl
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.
- cleat/__init__.py +8 -0
- cleat/engine.py +477 -0
- cleat/filewatch.py +86 -0
- cleat/inject.py +107 -0
- cleat/microterm.py +140 -0
- cleat/server.py +126 -0
- cleat/structure.py +183 -0
- cleat/vendor/bash-preexec.LICENSE.md +21 -0
- cleat/vendor/bash-preexec.sh +564 -0
- cleat-0.1.0.dist-info/METADATA +159 -0
- cleat-0.1.0.dist-info/RECORD +14 -0
- cleat-0.1.0.dist-info/WHEEL +4 -0
- cleat-0.1.0.dist-info/entry_points.txt +2 -0
- cleat-0.1.0.dist-info/licenses/LICENSE +27 -0
cleat/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""cleat - a headless terminal layer for AI agents.
|
|
2
|
+
|
|
3
|
+
A persistent PTY shell session whose byte stream is parsed for OSC 133 marks and
|
|
4
|
+
exposed to an agent over MCP as structured results (stdout, exit code, files
|
|
5
|
+
touched), plus a virtual screen for interactive programs and TUIs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
cleat/engine.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
engine.py - the persistent PTY engine.
|
|
4
|
+
|
|
5
|
+
microterm.py is the *interactive* pump (human types, human reads). The engine is
|
|
6
|
+
the *programmatic* version: it holds the master side of a PTY around a long-lived
|
|
7
|
+
shell and exposes a small API an MCP server can drive.
|
|
8
|
+
|
|
9
|
+
It reuses `ptyprocess` for the PTY plumbing, injects OSC 133 via inject.py, and
|
|
10
|
+
feeds the raw byte stream into the StructureSource. A background thread reads the
|
|
11
|
+
PTY continuously into two places at once:
|
|
12
|
+
- the StructureSource, which closes a CommandRecord on each C->D pair
|
|
13
|
+
- a raw byte buffer with a cursor, so callers can read partial output
|
|
14
|
+
|
|
15
|
+
Two completion signals, because the D mark alone isn't enough:
|
|
16
|
+
- D mark -> the command finished; we have a real exit code.
|
|
17
|
+
- output idle -> bytes stopped flowing with no D; the program is probably
|
|
18
|
+
waiting for input (a REPL/prompt) or just paused. We return
|
|
19
|
+
what we have and say completed=False instead of hanging.
|
|
20
|
+
|
|
21
|
+
API:
|
|
22
|
+
eng = Engine().start()
|
|
23
|
+
eng.run_command("ls") # {stdout, exit_code, completed}
|
|
24
|
+
eng.run_command("python3", timeout=5) # completed=False, stdout has the banner+'>>>'
|
|
25
|
+
eng.send_keys("print(6*7)", enter=True)# {output, exit_code, completed}
|
|
26
|
+
eng.read_output() # poll a long-runner; {output, exit_code, completed}
|
|
27
|
+
eng.close()
|
|
28
|
+
|
|
29
|
+
Scope: line-oriented interactive programs (REPLs, prompts, streaming output).
|
|
30
|
+
Full-screen TUIs (vim/top) emit cursor-addressing that only means anything when
|
|
31
|
+
rendered into a screen grid - that needs a terminal emulator (pyte) and is a
|
|
32
|
+
separate step, not handled here.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
import time
|
|
37
|
+
import shutil
|
|
38
|
+
import threading
|
|
39
|
+
|
|
40
|
+
import ptyprocess
|
|
41
|
+
import pyte
|
|
42
|
+
|
|
43
|
+
from . import filewatch
|
|
44
|
+
from .structure import StructureSource, _clean
|
|
45
|
+
from .inject import prepare
|
|
46
|
+
|
|
47
|
+
# Keep the raw-byte window bounded; the consumed prefix is dropped past this.
|
|
48
|
+
_MAX_RAW = 1 << 20 # 1 MiB
|
|
49
|
+
# Keep only the most recent records; older ones are evicted (callers use
|
|
50
|
+
# absolute indices via _rec_base, so eviction is transparent).
|
|
51
|
+
_MAX_RECORDS = 256
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _serialized(method):
|
|
55
|
+
"""Serialize agent-facing calls: one command/read drives the single shell at
|
|
56
|
+
a time, so two concurrent tool calls can't interleave writes on the PTY or
|
|
57
|
+
cross their record correlation. (Held across the call's internal waits.)"""
|
|
58
|
+
def wrapper(self, *args, **kwargs):
|
|
59
|
+
with self._api_lock:
|
|
60
|
+
return method(self, *args, **kwargs)
|
|
61
|
+
wrapper.__name__ = method.__name__
|
|
62
|
+
wrapper.__doc__ = method.__doc__
|
|
63
|
+
return wrapper
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Engine:
|
|
67
|
+
def __init__(self, shell=None, inject=True, cols=120, rows=40, watch_root=None):
|
|
68
|
+
self.shell = shell or os.environ.get("SHELL", "/bin/zsh")
|
|
69
|
+
self.inject = inject
|
|
70
|
+
self.dims = (rows, cols)
|
|
71
|
+
self._watch_root = watch_root # if set, run_command reports files_changed
|
|
72
|
+
self._proc = None
|
|
73
|
+
self._inject_dir = None
|
|
74
|
+
self._struct = StructureSource()
|
|
75
|
+
|
|
76
|
+
# Second consumer of the same byte stream: a virtual screen. pyte
|
|
77
|
+
# interprets cursor moves/clears/colors so we can read what the terminal
|
|
78
|
+
# *looks like* (clean REPL lines, rendered TUIs) - it ignores OSC 133,
|
|
79
|
+
# which the StructureSource handles instead.
|
|
80
|
+
self._screen = pyte.Screen(cols, rows)
|
|
81
|
+
self._pyte = pyte.ByteStream(self._screen)
|
|
82
|
+
|
|
83
|
+
# Shared state, guarded by _cond. The reader thread is the only writer;
|
|
84
|
+
# callers read under the lock and wait on the cond. To stay bounded over
|
|
85
|
+
# a long-lived session, both buffers keep only a recent window and track
|
|
86
|
+
# an absolute base index of element [0], so positions/indices are
|
|
87
|
+
# absolute (eviction-invariant) and old data can be dropped. run_command
|
|
88
|
+
# takes the FIRST new record (a command may emit two marks - e.g. fish
|
|
89
|
+
# 4.x native + our injected - and only the first carries the output).
|
|
90
|
+
self._raw = bytearray() # recent window of bytes the shell emitted
|
|
91
|
+
self._base = 0 # absolute index of _raw[0] (bytes dropped)
|
|
92
|
+
self._cursor = 0 # absolute index of next unconsumed byte
|
|
93
|
+
self._records = [] # recent CommandRecords
|
|
94
|
+
self._rec_base = 0 # absolute index of _records[0] (count evicted)
|
|
95
|
+
self._cond = threading.Condition()
|
|
96
|
+
self._api_lock = threading.Lock() # serializes agent-facing calls
|
|
97
|
+
self._reader = None
|
|
98
|
+
self._alive = False
|
|
99
|
+
|
|
100
|
+
# -- lifecycle ----------------------------------------------------------
|
|
101
|
+
def start(self):
|
|
102
|
+
base_env = os.environ.copy()
|
|
103
|
+
if self.inject:
|
|
104
|
+
argv, env, self._inject_dir = prepare(self.shell, base_env)
|
|
105
|
+
else:
|
|
106
|
+
argv, env = [self.shell], base_env
|
|
107
|
+
# A real terminal always sets TERM; an MCP server spawned without a tty
|
|
108
|
+
# (or CI) may not, which breaks tput/vim/less/fish. We render an xterm via
|
|
109
|
+
# pyte, so advertise that when nothing else is set.
|
|
110
|
+
env.setdefault("TERM", "xterm-256color")
|
|
111
|
+
self._proc = ptyprocess.PtyProcess.spawn(
|
|
112
|
+
argv, env=env, dimensions=self.dims
|
|
113
|
+
)
|
|
114
|
+
self._alive = True
|
|
115
|
+
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
116
|
+
self._reader.start()
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def _read_loop(self):
|
|
120
|
+
while self._alive:
|
|
121
|
+
try:
|
|
122
|
+
data = self._proc.read(4096)
|
|
123
|
+
except (EOFError, OSError):
|
|
124
|
+
break
|
|
125
|
+
if not data:
|
|
126
|
+
break
|
|
127
|
+
self._answer_terminal_queries(data)
|
|
128
|
+
with self._cond:
|
|
129
|
+
# Feed under the lock so struct state (commands_started), the
|
|
130
|
+
# raw buffer, and the screen advance atomically for readers.
|
|
131
|
+
recs = self._struct.feed(data)
|
|
132
|
+
self._raw += data
|
|
133
|
+
self._records.extend(recs)
|
|
134
|
+
try:
|
|
135
|
+
self._pyte.feed(data)
|
|
136
|
+
except Exception:
|
|
137
|
+
pass # never let a rendering hiccup kill the read loop
|
|
138
|
+
# Bound memory: drop the consumed raw prefix + evict old records.
|
|
139
|
+
if len(self._raw) > _MAX_RAW:
|
|
140
|
+
drop = self._cursor - self._base
|
|
141
|
+
if drop > 0:
|
|
142
|
+
del self._raw[:drop]
|
|
143
|
+
self._base += drop
|
|
144
|
+
if len(self._records) > _MAX_RECORDS:
|
|
145
|
+
drop = len(self._records) - _MAX_RECORDS
|
|
146
|
+
del self._records[:drop]
|
|
147
|
+
self._rec_base += drop
|
|
148
|
+
self._cond.notify_all()
|
|
149
|
+
with self._cond:
|
|
150
|
+
self._alive = False
|
|
151
|
+
self._cond.notify_all()
|
|
152
|
+
|
|
153
|
+
def close(self):
|
|
154
|
+
self._alive = False
|
|
155
|
+
try:
|
|
156
|
+
self._proc.write(b"exit\n")
|
|
157
|
+
self._proc.close(force=True)
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
if self._inject_dir:
|
|
161
|
+
shutil.rmtree(self._inject_dir, ignore_errors=True)
|
|
162
|
+
|
|
163
|
+
def __enter__(self):
|
|
164
|
+
return self.start()
|
|
165
|
+
|
|
166
|
+
def __exit__(self, *exc):
|
|
167
|
+
self.close()
|
|
168
|
+
|
|
169
|
+
def resize(self, cols, rows):
|
|
170
|
+
"""Resize both the PTY and the virtual screen so TUIs relay out."""
|
|
171
|
+
with self._cond:
|
|
172
|
+
self.dims = (rows, cols)
|
|
173
|
+
try:
|
|
174
|
+
self._proc.setwinsize(rows, cols)
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
self._screen.resize(rows, cols)
|
|
178
|
+
return {"cols": cols, "rows": rows}
|
|
179
|
+
|
|
180
|
+
def set_watch_root(self, path):
|
|
181
|
+
"""Enable/disable the files-touched feature. path='' or None disables."""
|
|
182
|
+
self._watch_root = os.path.abspath(path) if path else None
|
|
183
|
+
return {"watch_root": self._watch_root}
|
|
184
|
+
|
|
185
|
+
# -- internals ----------------------------------------------------------
|
|
186
|
+
def _answer_terminal_queries(self, data):
|
|
187
|
+
"""Reply to terminal capability queries so probing programs don't block.
|
|
188
|
+
|
|
189
|
+
Some shells/TUIs (notably fish 4.x) refuse to draw a prompt until the
|
|
190
|
+
terminal answers DA1 / cursor-position / background-color queries. Under
|
|
191
|
+
a bare PTY nobody answers, so they hang forever. We send minimal canned
|
|
192
|
+
replies. Harmless for shells that never ask (zsh/bash)."""
|
|
193
|
+
try:
|
|
194
|
+
if b"\x1b[c" in data or b"\x1b[0c" in data:
|
|
195
|
+
self._proc.write(b"\x1b[?62;c") # DA1
|
|
196
|
+
if b"\x1b[6n" in data:
|
|
197
|
+
self._proc.write(b"\x1b[1;1R") # cursor pos
|
|
198
|
+
if b"\x1b]11;?" in data:
|
|
199
|
+
self._proc.write(b"\x1b]11;rgb:0000/0000/0000\x1b\\") # bg color
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
def _total(self):
|
|
204
|
+
"""Absolute count of bytes ever emitted. Caller holds _cond."""
|
|
205
|
+
return self._base + len(self._raw)
|
|
206
|
+
|
|
207
|
+
def _rec_total(self):
|
|
208
|
+
"""Absolute count of records ever produced. Caller holds _cond."""
|
|
209
|
+
return self._rec_base + len(self._records)
|
|
210
|
+
|
|
211
|
+
def _drain(self):
|
|
212
|
+
"""Return raw bytes since the cursor and advance it. Caller holds _cond."""
|
|
213
|
+
chunk = bytes(self._raw[self._cursor - self._base:])
|
|
214
|
+
self._cursor = self._total()
|
|
215
|
+
return chunk
|
|
216
|
+
|
|
217
|
+
def _render_screen(self):
|
|
218
|
+
"""Snapshot the virtual screen as text + cursor. Caller holds _cond."""
|
|
219
|
+
lines = [line.rstrip() for line in self._screen.display]
|
|
220
|
+
while lines and not lines[-1]: # trim trailing blank rows
|
|
221
|
+
lines.pop()
|
|
222
|
+
return "\n".join(lines), [self._screen.cursor.x, self._screen.cursor.y]
|
|
223
|
+
|
|
224
|
+
def _read_until_idle(self, timeout, idle):
|
|
225
|
+
"""Collect output until it goes quiet for `idle`s or `timeout`s elapses.
|
|
226
|
+
Caller holds _cond. Returns the raw bytes collected."""
|
|
227
|
+
end = time.monotonic() + timeout
|
|
228
|
+
start_rc = self._rec_total()
|
|
229
|
+
out = bytearray()
|
|
230
|
+
# Wait for the first byte (or a record, or timeout).
|
|
231
|
+
while (self._cursor >= self._total()
|
|
232
|
+
and self._rec_total() == start_rc and self._alive):
|
|
233
|
+
remaining = end - time.monotonic()
|
|
234
|
+
if remaining <= 0:
|
|
235
|
+
break
|
|
236
|
+
self._cond.wait(remaining)
|
|
237
|
+
out += self._drain()
|
|
238
|
+
# Then keep collecting until an idle gap with nothing new.
|
|
239
|
+
while self._alive and time.monotonic() < end:
|
|
240
|
+
self._cond.wait(idle)
|
|
241
|
+
new = self._drain()
|
|
242
|
+
if new:
|
|
243
|
+
out += new
|
|
244
|
+
continue
|
|
245
|
+
break
|
|
246
|
+
return bytes(out)
|
|
247
|
+
|
|
248
|
+
# -- the agent-facing API ----------------------------------------------
|
|
249
|
+
@_serialized
|
|
250
|
+
def run_command(self, cmd, timeout=10.0, idle=0.4) -> dict:
|
|
251
|
+
"""Run a command. Returns {stdout, exit_code, completed}.
|
|
252
|
+
|
|
253
|
+
completed=True -> a D mark closed the command; exit_code is real.
|
|
254
|
+
completed=False -> output went idle with no D: the program is waiting
|
|
255
|
+
for input or still running. stdout is what we have so
|
|
256
|
+
far; follow up with send_keys()/read_output().
|
|
257
|
+
"""
|
|
258
|
+
if not self._alive:
|
|
259
|
+
raise RuntimeError("engine not started (or already closed)")
|
|
260
|
+
before = trunc_before = None
|
|
261
|
+
if self._watch_root:
|
|
262
|
+
before, trunc_before = filewatch.snapshot(self._watch_root)
|
|
263
|
+
with self._cond:
|
|
264
|
+
start_rc = self._rec_total()
|
|
265
|
+
start_started = self._struct.commands_started
|
|
266
|
+
self._proc.write((cmd + "\n").encode())
|
|
267
|
+
end = time.monotonic() + timeout
|
|
268
|
+
prev_len = self._total()
|
|
269
|
+
while self._alive:
|
|
270
|
+
if self._rec_total() > start_rc:
|
|
271
|
+
break
|
|
272
|
+
remaining = end - time.monotonic()
|
|
273
|
+
if remaining <= 0:
|
|
274
|
+
break
|
|
275
|
+
self._cond.wait(min(idle, remaining))
|
|
276
|
+
if self._rec_total() > start_rc:
|
|
277
|
+
break
|
|
278
|
+
cur_len = self._total()
|
|
279
|
+
c_seen = self._struct.commands_started > start_started
|
|
280
|
+
# Bail to "interactive" only when the command has started AND the
|
|
281
|
+
# parser has captured REAL stdout (not just terminal chrome like a
|
|
282
|
+
# title-set OSC) AND this wait added nothing. Keying on
|
|
283
|
+
# partial_stdout (ANSI/OSC-stripped) ignores the command echo
|
|
284
|
+
# (pre-C), silent commands like `sleep`, and post-C chrome that
|
|
285
|
+
# some shells (fish) emit before any output.
|
|
286
|
+
idle_now = (cur_len == prev_len)
|
|
287
|
+
has_real_output = c_seen and bool(self._struct.partial_stdout())
|
|
288
|
+
if idle_now and has_real_output:
|
|
289
|
+
break
|
|
290
|
+
prev_len = cur_len
|
|
291
|
+
|
|
292
|
+
if self._rec_total() > start_rc:
|
|
293
|
+
# FIRST new record (a doubled-mark command's 2nd record is empty).
|
|
294
|
+
rec = self._records[start_rc - self._rec_base]
|
|
295
|
+
self._cursor = self._total()
|
|
296
|
+
result = {"stdout": rec.stdout, "exit_code": rec.exit_code,
|
|
297
|
+
"completed": True}
|
|
298
|
+
else:
|
|
299
|
+
# Not completed: hand back the clean post-C output if we have it.
|
|
300
|
+
self._cursor = self._total()
|
|
301
|
+
result = {"stdout": self._struct.partial_stdout(),
|
|
302
|
+
"exit_code": None, "completed": False}
|
|
303
|
+
|
|
304
|
+
# files-touched: diff the watched tree once the command has finished.
|
|
305
|
+
if before is not None and result["completed"]:
|
|
306
|
+
after, trunc_after = filewatch.snapshot(self._watch_root)
|
|
307
|
+
changed = filewatch.diff(before, after)
|
|
308
|
+
if trunc_before or trunc_after:
|
|
309
|
+
changed["truncated"] = True # tree too big; result unreliable
|
|
310
|
+
result["files_changed"] = changed
|
|
311
|
+
return result
|
|
312
|
+
|
|
313
|
+
@_serialized
|
|
314
|
+
def read_output(self, timeout=2.0, idle=0.4) -> dict:
|
|
315
|
+
"""Poll for output without sending anything (e.g. watch a long-runner).
|
|
316
|
+
Returns {output, exit_code, completed}; exit_code is set if a command
|
|
317
|
+
finished while we were reading."""
|
|
318
|
+
if not self._alive:
|
|
319
|
+
raise RuntimeError("engine not started (or already closed)")
|
|
320
|
+
with self._cond:
|
|
321
|
+
start_rc = self._rec_total()
|
|
322
|
+
raw = self._read_until_idle(timeout, idle)
|
|
323
|
+
done = self._rec_total() > start_rc
|
|
324
|
+
exit_code = self._records[-1].exit_code if done else None
|
|
325
|
+
return {"output": _clean(raw), "exit_code": exit_code,
|
|
326
|
+
"completed": done}
|
|
327
|
+
|
|
328
|
+
@_serialized
|
|
329
|
+
def read_screen(self, settle=0.3, timeout=1.0) -> dict:
|
|
330
|
+
"""Return the rendered virtual screen (what the terminal looks like now)
|
|
331
|
+
plus the cursor [x, y]. Briefly waits for output to settle first so a
|
|
332
|
+
mid-redraw frame isn't captured. Use this for TUIs and REPLs; use
|
|
333
|
+
read_output() for streaming text you don't want truncated to the screen."""
|
|
334
|
+
if not self._alive:
|
|
335
|
+
raise RuntimeError("engine not started (or already closed)")
|
|
336
|
+
with self._cond:
|
|
337
|
+
self._read_until_idle(timeout, settle) # flush pending bytes
|
|
338
|
+
screen, cursor = self._render_screen()
|
|
339
|
+
return {"screen": screen, "cursor": cursor}
|
|
340
|
+
|
|
341
|
+
@_serialized
|
|
342
|
+
def send_keys(self, keys, enter=False, timeout=2.0, idle=0.4) -> dict:
|
|
343
|
+
"""Send raw input to the running program, then return the rendered screen.
|
|
344
|
+
|
|
345
|
+
Control chars go through as-is: "\\u0003"=Ctrl-C, "\\u0004"=Ctrl-D.
|
|
346
|
+
Set enter=True to append a newline. Returns {screen, exit_code,
|
|
347
|
+
completed}; the screen is pyte-rendered so REPL/TUI output is clean (no
|
|
348
|
+
per-keystroke redraw noise). completed=True (with exit_code) if the
|
|
349
|
+
program exited.
|
|
350
|
+
"""
|
|
351
|
+
if not self._alive:
|
|
352
|
+
raise RuntimeError("engine not started (or already closed)")
|
|
353
|
+
payload = keys + ("\n" if enter else "")
|
|
354
|
+
with self._cond:
|
|
355
|
+
start_rc = self._rec_total()
|
|
356
|
+
self._proc.write(payload.encode())
|
|
357
|
+
self._read_until_idle(timeout, idle)
|
|
358
|
+
done = self._rec_total() > start_rc
|
|
359
|
+
exit_code = self._records[-1].exit_code if done else None
|
|
360
|
+
screen, cursor = self._render_screen()
|
|
361
|
+
return {"screen": screen, "cursor": cursor, "exit_code": exit_code,
|
|
362
|
+
"completed": done}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
# Self-test: drive a real shell, including interactive programs. Headless.
|
|
367
|
+
if __name__ == "__main__":
|
|
368
|
+
eng = Engine().start()
|
|
369
|
+
results = []
|
|
370
|
+
|
|
371
|
+
def check(label, ok, detail=""):
|
|
372
|
+
results.append(ok)
|
|
373
|
+
print(f"{'ok ' if ok else 'FAIL'} {label}{(' -> ' + detail) if detail else ''}")
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
r = eng.run_command("echo hello")
|
|
377
|
+
check("one-shot echo", r == {"stdout": "hello", "exit_code": 0, "completed": True}, str(r))
|
|
378
|
+
|
|
379
|
+
r = eng.run_command("false")
|
|
380
|
+
check("exit code 1", r["exit_code"] == 1 and r["completed"], str(r))
|
|
381
|
+
|
|
382
|
+
r = eng.run_command("export FOO=bar")
|
|
383
|
+
r = eng.run_command("echo $FOO")
|
|
384
|
+
check("persistence", r["stdout"] == "bar", str(r))
|
|
385
|
+
|
|
386
|
+
# long-running but silent until the end: should COMPLETE (no false idle).
|
|
387
|
+
r = eng.run_command("sleep 1; echo woke", timeout=5)
|
|
388
|
+
check("slow-but-completes", r["completed"] and r["stdout"] == "woke", str(r))
|
|
389
|
+
|
|
390
|
+
# interactive REPL: run_command should NOT hang; returns completed=False.
|
|
391
|
+
r = eng.run_command("python3", timeout=8)
|
|
392
|
+
check("repl starts (not completed)",
|
|
393
|
+
(not r["completed"]) and (">>>" in r["stdout"]), repr(r["stdout"][-40:]))
|
|
394
|
+
|
|
395
|
+
r = eng.send_keys("print(6*7)", enter=True)
|
|
396
|
+
# screen-rendered: the answer is present AND the per-keystroke redraw
|
|
397
|
+
# noise (">>> p>>> pr") is gone.
|
|
398
|
+
check("repl computes (clean screen)",
|
|
399
|
+
"42" in r["screen"] and ">>> p>>> pr" not in r["screen"], repr(r["screen"][-60:]))
|
|
400
|
+
|
|
401
|
+
r = eng.send_keys("exit()", enter=True)
|
|
402
|
+
check("repl exits (exit code)", r["completed"] and r["exit_code"] is not None, str(r))
|
|
403
|
+
|
|
404
|
+
# interrupt a hung command with Ctrl-C.
|
|
405
|
+
r = eng.run_command("sleep 30", timeout=1.0)
|
|
406
|
+
check("sleep not completed", not r["completed"], str(r))
|
|
407
|
+
r = eng.send_keys("") # Ctrl-C
|
|
408
|
+
check("ctrl-c interrupts", r["completed"] and r["exit_code"] is not None, str(r))
|
|
409
|
+
|
|
410
|
+
# full-screen TUI: vim should render (empty buffer shows '~' rows), then quit.
|
|
411
|
+
r = eng.run_command("vim -u NONE -N", timeout=4)
|
|
412
|
+
scr = eng.read_screen()
|
|
413
|
+
check("vim renders (TUI)", "~" in scr["screen"], repr(scr["screen"][:60]))
|
|
414
|
+
r = eng.send_keys("\x1b:q!", enter=True) # ESC then :q!
|
|
415
|
+
check("vim quits", r["completed"], str({k: r[k] for k in ("exit_code", "completed")}))
|
|
416
|
+
|
|
417
|
+
# (3) dynamic resize: PTY width should follow.
|
|
418
|
+
eng.resize(80, 24)
|
|
419
|
+
r = eng.run_command("tput cols")
|
|
420
|
+
check("resize -> tput cols=80", r["stdout"] == "80", str(r))
|
|
421
|
+
|
|
422
|
+
# (2) files a command touched: watch a temp dir, mutate it, see the diff.
|
|
423
|
+
import tempfile as _tf
|
|
424
|
+
wd = _tf.mkdtemp(prefix="engine-watch-")
|
|
425
|
+
eng.set_watch_root(wd)
|
|
426
|
+
r = eng.run_command(f"touch {wd}/created.txt")
|
|
427
|
+
fc = r.get("files_changed", {})
|
|
428
|
+
check("files_changed reports create",
|
|
429
|
+
fc.get("created") == [os.path.join(wd, "created.txt")], str(fc))
|
|
430
|
+
eng.set_watch_root(None)
|
|
431
|
+
shutil.rmtree(wd, ignore_errors=True)
|
|
432
|
+
|
|
433
|
+
# memory bound: push >1 MiB of output through; the consumed raw prefix
|
|
434
|
+
# must be dropped (so _base advances and _raw stays bounded).
|
|
435
|
+
for _ in range(8):
|
|
436
|
+
eng.run_command("head -c 200000 /dev/zero | tr '\\0' x", timeout=8)
|
|
437
|
+
check("raw buffer bounded after >1.5MiB output",
|
|
438
|
+
len(eng._raw) <= 2 * _MAX_RAW and eng._base > 0,
|
|
439
|
+
f"len(_raw)={len(eng._raw)} base={eng._base}")
|
|
440
|
+
|
|
441
|
+
print("\nALL PASS" if all(results) else "\nSOME FAILED")
|
|
442
|
+
finally:
|
|
443
|
+
eng.close()
|
|
444
|
+
|
|
445
|
+
# (1) bash injection (bash-preexec): a SEPARATE engine on /bin/bash.
|
|
446
|
+
print("\n--- bash injection (bash-preexec) ---")
|
|
447
|
+
if os.path.exists("/bin/bash"):
|
|
448
|
+
beng = Engine(shell="/bin/bash").start()
|
|
449
|
+
try:
|
|
450
|
+
rb = beng.run_command("echo hi")
|
|
451
|
+
print(f"{'ok ' if rb == {'stdout':'hi','exit_code':0,'completed':True} else 'FAIL'} bash echo -> {rb}")
|
|
452
|
+
rb = beng.run_command("false")
|
|
453
|
+
print(f"{'ok ' if rb['exit_code']==1 and rb['completed'] else 'FAIL'} bash false exit=1 -> {rb}")
|
|
454
|
+
# subshell first-token: no C mark; parser must still recover exit code.
|
|
455
|
+
rb = beng.run_command("(exit 7)", timeout=4)
|
|
456
|
+
print(f"{'ok ' if rb['exit_code']==7 and rb['completed'] else 'FAIL'} bash subshell exit=7 (no-C recovery) -> {rb}")
|
|
457
|
+
finally:
|
|
458
|
+
beng.close()
|
|
459
|
+
else:
|
|
460
|
+
print("skip - /bin/bash not found")
|
|
461
|
+
|
|
462
|
+
# fish injection: needs the terminal-query responder so fish 4.x doesn't hang.
|
|
463
|
+
print("\n--- fish injection ---")
|
|
464
|
+
fish = shutil.which("fish")
|
|
465
|
+
if fish:
|
|
466
|
+
feng = Engine(shell=fish).start()
|
|
467
|
+
try:
|
|
468
|
+
rf = feng.run_command("echo hi", timeout=8)
|
|
469
|
+
print(f"{'ok ' if rf == {'stdout':'hi','exit_code':0,'completed':True} else 'FAIL'} fish echo -> {rf}")
|
|
470
|
+
rf = feng.run_command("false")
|
|
471
|
+
print(f"{'ok ' if rf['exit_code']==1 and rf['completed'] else 'FAIL'} fish false exit=1 -> {rf}")
|
|
472
|
+
rf = feng.run_command("echo second")
|
|
473
|
+
print(f"{'ok ' if rf['stdout']=='second' else 'FAIL'} fish no-drift -> {rf}")
|
|
474
|
+
finally:
|
|
475
|
+
feng.close()
|
|
476
|
+
else:
|
|
477
|
+
print("skip - fish not found")
|
cleat/filewatch.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
filewatch.py - "what files did this command touch?" via snapshot/diff.
|
|
4
|
+
|
|
5
|
+
A bounded mtime+size snapshot of a directory tree, diffed before/after a command,
|
|
6
|
+
yields the files it created / modified / deleted. Because the engine runs one
|
|
7
|
+
shell serially, attribution to the command is safe.
|
|
8
|
+
|
|
9
|
+
HONEST SCOPE: this detects WRITES (create/modify/delete), not READS. Tracking
|
|
10
|
+
reads needs syscall tracing (dtrace/strace), which requires root and is blocked
|
|
11
|
+
by SIP on macOS. So this is the writes-half of "files touched" - useful and
|
|
12
|
+
cheap, with no extra dependency.
|
|
13
|
+
|
|
14
|
+
Cost control: ignores noisy/huge dirs (.git, node_modules, venvs, caches) and
|
|
15
|
+
caps the file count; if capped, `truncated` is True and the diff is unreliable
|
|
16
|
+
(point watch_files() at a specific project dir, not $HOME).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
IGNORE_DIRS = {
|
|
23
|
+
".git", "node_modules", "__pycache__", ".venv", "venv", "headless-venv",
|
|
24
|
+
".mypy_cache", ".pytest_cache", ".idea", ".tox", ".gradle", "target",
|
|
25
|
+
}
|
|
26
|
+
MAX_FILES = 50_000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def snapshot(root):
|
|
30
|
+
"""Map path -> (mtime_ns, size) for files under root. Returns (snap, truncated)."""
|
|
31
|
+
snap = {}
|
|
32
|
+
truncated = False
|
|
33
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
34
|
+
dirnames[:] = [d for d in dirnames if d not in IGNORE_DIRS]
|
|
35
|
+
for name in filenames:
|
|
36
|
+
p = os.path.join(dirpath, name)
|
|
37
|
+
try:
|
|
38
|
+
st = os.lstat(p)
|
|
39
|
+
except OSError:
|
|
40
|
+
continue
|
|
41
|
+
snap[p] = (st.st_mtime_ns, st.st_size)
|
|
42
|
+
if len(snap) >= MAX_FILES:
|
|
43
|
+
return snap, True
|
|
44
|
+
return snap, truncated
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def diff(before, after):
|
|
48
|
+
"""Compare two snapshots -> {created, modified, deleted} (sorted path lists)."""
|
|
49
|
+
bset, aset = set(before), set(after)
|
|
50
|
+
created = sorted(aset - bset)
|
|
51
|
+
deleted = sorted(bset - aset)
|
|
52
|
+
modified = sorted(p for p in (aset & bset) if before[p] != after[p])
|
|
53
|
+
return {"created": created, "modified": modified, "deleted": deleted}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
# Self-test: create/modify/delete under a temp dir and check the diff.
|
|
58
|
+
import tempfile
|
|
59
|
+
import shutil
|
|
60
|
+
|
|
61
|
+
d = tempfile.mkdtemp(prefix="filewatch-test-")
|
|
62
|
+
try:
|
|
63
|
+
keep = os.path.join(d, "keep.txt")
|
|
64
|
+
gone = os.path.join(d, "gone.txt")
|
|
65
|
+
with open(keep, "w") as f:
|
|
66
|
+
f.write("v1")
|
|
67
|
+
with open(gone, "w") as f:
|
|
68
|
+
f.write("bye")
|
|
69
|
+
|
|
70
|
+
before, _ = snapshot(d)
|
|
71
|
+
# mutate: create one, modify one, delete one.
|
|
72
|
+
new = os.path.join(d, "new.txt")
|
|
73
|
+
with open(new, "w") as f:
|
|
74
|
+
f.write("hi")
|
|
75
|
+
with open(keep, "w") as f:
|
|
76
|
+
f.write("v2-longer")
|
|
77
|
+
os.remove(gone)
|
|
78
|
+
after, _ = snapshot(d)
|
|
79
|
+
|
|
80
|
+
result = diff(before, after)
|
|
81
|
+
ok = (result["created"] == [new]
|
|
82
|
+
and result["modified"] == [keep]
|
|
83
|
+
and result["deleted"] == [gone])
|
|
84
|
+
print("ok filewatch diff" if ok else f"FAIL {result}")
|
|
85
|
+
finally:
|
|
86
|
+
shutil.rmtree(d, ignore_errors=True)
|
cleat/inject.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
inject.py - OSC 133 shell-integration injection (an engine concern).
|
|
4
|
+
|
|
5
|
+
We want the shells we spawn to emit FinalTerm/OSC 133 marks WITHOUT touching the
|
|
6
|
+
user's real config. Each shell has its own injection seam:
|
|
7
|
+
|
|
8
|
+
zsh -> $ZDOTDIR points at a temp dir whose .zshrc re-sources the user's
|
|
9
|
+
config then installs the marks.
|
|
10
|
+
bash -> --rcfile <tempfile> that sources ~/.bashrc then installs the marks.
|
|
11
|
+
fish -> no injection: fish >= 4 emits OSC 133 natively (injecting too would
|
|
12
|
+
double the marks). fish < 4 is unsupported.
|
|
13
|
+
|
|
14
|
+
prepare(shell, env) returns (argv, env, cleanup_dir): the argv to spawn, the env
|
|
15
|
+
to spawn it with, and a temp dir to rmtree on exit (or None). Shared by
|
|
16
|
+
microterm.py (interactive demonstrator) and engine.py. No third-party deps.
|
|
17
|
+
|
|
18
|
+
Parser contract (see structure.py): only C (output-begins) and D;<exit> matter.
|
|
19
|
+
A (prompt-start) is emitted where easy; B (prompt-end) is skipped.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import tempfile
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
OSC133_ZSHRC = r'''# --- headless terminal layer: injected zsh rcfile ---
|
|
27
|
+
ZDOTDIR="${_HEADLESS_REAL_ZDOTDIR:-$HOME}"
|
|
28
|
+
[ -f "$ZDOTDIR/.zshenv" ] && source "$ZDOTDIR/.zshenv"
|
|
29
|
+
[ -f "$ZDOTDIR/.zshrc" ] && source "$ZDOTDIR/.zshrc"
|
|
30
|
+
|
|
31
|
+
autoload -Uz add-zsh-hook
|
|
32
|
+
_h133_preexec() { printf '\033]133;C\007' }
|
|
33
|
+
_h133_precmd() { printf '\033]133;D;%s\007\033]133;A\007' "$?" }
|
|
34
|
+
add-zsh-hook preexec _h133_preexec
|
|
35
|
+
add-zsh-hook precmd _h133_precmd
|
|
36
|
+
|
|
37
|
+
# Keep the C->D region clean: drop zsh's partial-line indicator.
|
|
38
|
+
unsetopt PROMPT_SP 2>/dev/null
|
|
39
|
+
PROMPT_EOL_MARK=''
|
|
40
|
+
'''
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# @BASH_PREEXEC_PATH@ is filled in by prepare() with the ABSOLUTE path to the
|
|
44
|
+
# vendored bash-preexec.sh (it ships next to this module). The rcfile itself
|
|
45
|
+
# lives in a throwaway temp dir, so it must reference the vendored file by an
|
|
46
|
+
# absolute path. bash-preexec gives reliable preexec/precmd hook arrays that fire
|
|
47
|
+
# once per interactive command and - unlike a hand-rolled DEBUG-trap armed flag -
|
|
48
|
+
# is not fooled by a PS1 that runs command substitution (the bash 4.x/5.x bug).
|
|
49
|
+
OSC133_BASHRC = r'''# --- headless terminal layer: injected bash rcfile (bash-preexec) ---
|
|
50
|
+
[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc"
|
|
51
|
+
|
|
52
|
+
# Source vendored bash-preexec LAST (it preserves any PROMPT_COMMAND ~/.bashrc set).
|
|
53
|
+
if [ -r '@BASH_PREEXEC_PATH@' ]; then
|
|
54
|
+
source '@BASH_PREEXEC_PATH@'
|
|
55
|
+
|
|
56
|
+
# C: right before each command runs.
|
|
57
|
+
__h133_preexec() { printf '\033]133;C\007'; }
|
|
58
|
+
# D;<exit> + A: capture $? FIRST, then emit prev exit code + next-prompt mark.
|
|
59
|
+
__h133_precmd() {
|
|
60
|
+
local ec=$?
|
|
61
|
+
printf '\033]133;D;%s\007\033]133;A\007' "$ec"
|
|
62
|
+
}
|
|
63
|
+
preexec_functions+=(__h133_preexec)
|
|
64
|
+
precmd_functions+=(__h133_precmd)
|
|
65
|
+
fi
|
|
66
|
+
# NOTE: bash-preexec does NOT fire preexec when the command's first token is a
|
|
67
|
+
# subshell (...) or brace group { ...; } - such a command emits no C mark. The
|
|
68
|
+
# parser recovers its exit code as a zero-output record (see structure.py).
|
|
69
|
+
'''
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _write(dirpath, name, content):
|
|
73
|
+
path = os.path.join(dirpath, name)
|
|
74
|
+
with open(path, "w") as f:
|
|
75
|
+
f.write(content)
|
|
76
|
+
return path
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def prepare(shell, base_env):
|
|
80
|
+
"""Return (argv, env, cleanup_dir) to spawn `shell` with OSC 133 injected."""
|
|
81
|
+
base = os.path.basename(shell)
|
|
82
|
+
env = dict(base_env)
|
|
83
|
+
|
|
84
|
+
if base == "zsh":
|
|
85
|
+
d = tempfile.mkdtemp(prefix="headless-inj-")
|
|
86
|
+
_write(d, ".zshrc", OSC133_ZSHRC)
|
|
87
|
+
env["_HEADLESS_REAL_ZDOTDIR"] = env.get("ZDOTDIR", env.get("HOME", ""))
|
|
88
|
+
env["ZDOTDIR"] = d
|
|
89
|
+
return [shell], env, d
|
|
90
|
+
|
|
91
|
+
if base == "bash":
|
|
92
|
+
d = tempfile.mkdtemp(prefix="headless-inj-")
|
|
93
|
+
# bash-preexec.sh is vendored next to this module so its absolute path
|
|
94
|
+
# survives even though the rcfile is written into a throwaway temp dir.
|
|
95
|
+
bp = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
96
|
+
"vendor", "bash-preexec.sh")
|
|
97
|
+
rc = _write(d, "bashrc", OSC133_BASHRC.replace("@BASH_PREEXEC_PATH@", bp))
|
|
98
|
+
return [shell, "--rcfile", rc], env, d
|
|
99
|
+
|
|
100
|
+
if base == "fish":
|
|
101
|
+
# fish >= 4 emits OSC 133 natively, so we DON'T inject (doing so would
|
|
102
|
+
# double every mark and make correlation racy). fish < 4 has no native
|
|
103
|
+
# integration and is unsupported - it will simply produce no marks.
|
|
104
|
+
return [shell], env, None
|
|
105
|
+
|
|
106
|
+
# Unknown shell: spawn as-is, no marks (structure source will see nothing).
|
|
107
|
+
return [shell], env, None
|