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 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