smartcli-toolkit 0.1.3__tar.gz → 0.1.5__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.
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/PKG-INFO +46 -15
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/README.md +41 -14
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/pyproject.toml +8 -1
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/smartcli_core/__init__.py +1 -1
- smartcli_toolkit-0.1.5/smartcli_core/__main__.py +122 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/smartcli_core/screen_model.py +26 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/smartcli_core/session.py +15 -1
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/smartcli_toolkit.egg-info/PKG-INFO +46 -15
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/smartcli_toolkit.egg-info/SOURCES.txt +4 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/smartcli_toolkit.egg-info/requires.txt +6 -0
- smartcli_toolkit-0.1.5/tests/test_char_width.py +99 -0
- smartcli_toolkit-0.1.5/tests/test_cpr_reply.py +136 -0
- smartcli_toolkit-0.1.5/tests/test_golden_frames.py +189 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/LICENSE +0 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/setup.cfg +0 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/smartcli_core/pty_backend.py +0 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/smartcli_core/py.typed +0 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/smartcli_core/readiness.py +0 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/smartcli_core/snapshot.py +0 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/smartcli_toolkit.egg-info/dependency_links.txt +0 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/smartcli_toolkit.egg-info/top_level.txt +0 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/tests/test_degenerate_inputs.py +0 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/tests/test_doc_counts.py +0 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/tests/test_fx_contract.py +0 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/tests/test_readiness.py +0 -0
- {smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/tests/test_vendor_sync.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: smartcli-toolkit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core).
|
|
5
5
|
Author: dwgx
|
|
6
6
|
License-Expression: MIT
|
|
@@ -38,9 +38,17 @@ Provides-Extra: all
|
|
|
38
38
|
Requires-Dist: pyfiglet>=1.0.0; extra == "all"
|
|
39
39
|
Requires-Dist: Pillow>=10.0.0; extra == "all"
|
|
40
40
|
Requires-Dist: wcwidth>=0.2.0; extra == "all"
|
|
41
|
+
Provides-Extra: mcp
|
|
42
|
+
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: coverage[toml]>=7.0; extra == "dev"
|
|
41
45
|
Dynamic: license-file
|
|
42
46
|
|
|
43
47
|
<!-- Language: English | [简体中文](docs/i18n/README.zh-Hans.md) | [繁體中文](docs/i18n/README.zh-Hant.md) | [日本語](docs/i18n/README.ja.md) | [한국어](docs/i18n/README.ko.md) -->
|
|
48
|
+
<!-- mcp-name: io.github.dwgx/smartcli -->
|
|
49
|
+
<!-- ^ MCP Registry PyPI ownership marker: this string must appear in the
|
|
50
|
+
published package's README (= PyPI description) and match server.json's
|
|
51
|
+
"name". Do not remove or change without updating server.json. -->
|
|
44
52
|
|
|
45
53
|
# SmartCLI
|
|
46
54
|
|
|
@@ -51,11 +59,26 @@ Dynamic: license-file
|
|
|
51
59
|
[](https://pypi.org/project/smartcli-toolkit/)
|
|
52
60
|
[](https://pypi.org/project/smartcli-toolkit/)
|
|
53
61
|
[](https://github.com/dwgx/SmartCLI/actions/workflows/ci.yml)
|
|
62
|
+
[](https://codecov.io/gh/dwgx/SmartCLI)
|
|
54
63
|
[](LICENSE)
|
|
55
64
|
[](https://pypi.org/project/smartcli-toolkit/)
|
|
56
65
|
[](#features)
|
|
57
66
|
[](#install)
|
|
58
67
|
|
|
68
|
+
**Let an AI drive, perceive, and render real terminal programs.** SmartCLI reads
|
|
69
|
+
the actual screen with a `pyte` cell model — not a byte pipe — so it knows which
|
|
70
|
+
menu row is highlighted, presses the right keys, and waits for the screen to
|
|
71
|
+
settle. Below: it drives the real **lazygit** TUI end-to-end (arrow-key
|
|
72
|
+
navigation, opening a commit diff, highlighting a branch) — no script, no mock.
|
|
73
|
+
|
|
74
|
+
<p align="center">
|
|
75
|
+
<img src="showcase/drive-lazygit.gif" alt="SmartCLI driving the real lazygit TUI: navigating panels, opening a commit diff, highlighting a branch" width="700">
|
|
76
|
+
</p>
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install smartcli-toolkit
|
|
80
|
+
```
|
|
81
|
+
|
|
59
82
|
## What & why
|
|
60
83
|
|
|
61
84
|
SmartCLI is a workspace for terminal work that agents and humans both do: **driving**
|
|
@@ -70,16 +93,12 @@ in place from the checkout.
|
|
|
70
93
|
|
|
71
94
|
## Driving a real TUI
|
|
72
95
|
|
|
73
|
-
SmartCLI driving **lazygit** — a real full-screen curses app —
|
|
74
|
-
perceive → act → confirm loop: it reads the `pyte` cell grid (which
|
|
75
|
-
selected, the alt-screen diff), moves with arrow keys, opens a commit's
|
|
76
|
-
and highlights a branch.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
<p align="center">
|
|
81
|
-
<img src="showcase/drive-lazygit.gif" alt="SmartCLI driving the real lazygit TUI: navigating panels, opening a commit diff, highlighting a branch" width="700">
|
|
82
|
-
</p>
|
|
96
|
+
The demo above is SmartCLI driving **lazygit** — a real full-screen curses app —
|
|
97
|
+
through its perceive → act → confirm loop: it reads the `pyte` cell grid (which
|
|
98
|
+
row is selected, the alt-screen diff), moves with arrow keys, opens a commit's
|
|
99
|
+
diff, and highlights a branch. Captured by driving the actual program in a Linux
|
|
100
|
+
container, not scripted or mocked. A byte-stream matcher like pexpect can't
|
|
101
|
+
perceive "which row is highlighted"; a screen model can.
|
|
83
102
|
|
|
84
103
|
> Honest scope: CI runs a Windows + Linux + macOS matrix. The POSIX pty backend
|
|
85
104
|
> (spawn / read / drive / resize / zombie-free terminate) is verified on Linux
|
|
@@ -166,13 +185,17 @@ set PYTHONIOENCODING=utf-8
|
|
|
166
185
|
Verified dep versions on the dev box (Windows 11, CPython 3.14.6): `pyte` 0.8.2,
|
|
167
186
|
`pywinpty` 3.0.5, `pyfiglet` 1.0.4, `Pillow` 12.2.0, `wcwidth` 0.8.1.
|
|
168
187
|
|
|
188
|
+
**Diagnostics.** `python -m smartcli_core` prints your OS, Python, terminal, PTY
|
|
189
|
+
backend, and dependency versions — run it and paste the output when filing a bug
|
|
190
|
+
(SmartCLI's behavior is very terminal- and platform-sensitive).
|
|
191
|
+
|
|
169
192
|
## Quickstart
|
|
170
193
|
|
|
171
194
|
### cmd-art — terminal visual effects
|
|
172
195
|
|
|
173
196
|
```bash
|
|
174
197
|
cd skills/cmd-art
|
|
175
|
-
python -m fx list # list all
|
|
198
|
+
python -m fx list # list all 22 effects
|
|
176
199
|
python -m fx play donut --seconds 5 # play one effect (bounded)
|
|
177
200
|
python -m fx gallery # one frame of each effect
|
|
178
201
|
python -m fx show --seq "donut:fire:3,plasma::3"
|
|
@@ -199,6 +222,14 @@ python skills/drive-tui/scripts/tui.py snapshot --id <SID>
|
|
|
199
222
|
python skills/drive-tui/scripts/tui.py close --id <SID>
|
|
200
223
|
```
|
|
201
224
|
|
|
225
|
+
Or drive from any MCP client — the same verbs as MCP tools, with the
|
|
226
|
+
per-session token attached automatically:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
pip install "smartcli-toolkit[mcp]"
|
|
230
|
+
python skills/drive-tui/scripts/mcp_server.py # stdio MCP server
|
|
231
|
+
```
|
|
232
|
+
|
|
202
233
|
### As a library
|
|
203
234
|
|
|
204
235
|
The shared core is importable directly:
|
|
@@ -220,9 +251,9 @@ suite, see **[`README-USAGE.md`](README-USAGE.md)**.
|
|
|
220
251
|
## Features
|
|
221
252
|
|
|
222
253
|
**`cmd-art`** (`skills/cmd-art`) — a "living-template" effect engine: an `Effect` ABC +
|
|
223
|
-
`@register` decorator + auto-discovery. **
|
|
254
|
+
`@register` decorator + auto-discovery. **22 effects** (donut, solarsystem, fire, plasma,
|
|
224
255
|
rain, starfield, tunnel, text3d, cube, sphere, boids, life, fireworks, sparkle, decrypt,
|
|
225
|
-
gradient_text, banner_scroll, image2ascii, typewriter) across **8 themes** (mono, fire,
|
|
256
|
+
gradient_text, banner_scroll, image2ascii, typewriter, julia, mandelbrot, perlin) across **8 themes** (mono, fire,
|
|
226
257
|
ocean, synthwave, viridis, pastel, matrix-green, rainbow). Effects are pure frame
|
|
227
258
|
producers; `play` is bounded by default and always restores the terminal.
|
|
228
259
|
|
|
@@ -254,7 +285,7 @@ cross-links. See [`knowledge/INDEX.md`](knowledge/INDEX.md).
|
|
|
254
285
|
```text
|
|
255
286
|
SmartCLI/
|
|
256
287
|
smartcli_core/ shared PTY + pyte engine (importable package)
|
|
257
|
-
skills/cmd-art/ fx effect package and CLI (
|
|
288
|
+
skills/cmd-art/ fx effect package and CLI (22 effects, 8 themes)
|
|
258
289
|
skills/drive-tui/ TUI pattern library and PTY driver CLI (8 recipes)
|
|
259
290
|
skills/tui-ui/ terminal UI layout engine and widgets (15 widgets)
|
|
260
291
|
tools/screenshot/ pyte -> PNG smoke-test harness
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
<!-- Language: English | [简体中文](docs/i18n/README.zh-Hans.md) | [繁體中文](docs/i18n/README.zh-Hant.md) | [日本語](docs/i18n/README.ja.md) | [한국어](docs/i18n/README.ko.md) -->
|
|
2
|
+
<!-- mcp-name: io.github.dwgx/smartcli -->
|
|
3
|
+
<!-- ^ MCP Registry PyPI ownership marker: this string must appear in the
|
|
4
|
+
published package's README (= PyPI description) and match server.json's
|
|
5
|
+
"name". Do not remove or change without updating server.json. -->
|
|
2
6
|
|
|
3
7
|
# SmartCLI
|
|
4
8
|
|
|
@@ -9,11 +13,26 @@
|
|
|
9
13
|
[](https://pypi.org/project/smartcli-toolkit/)
|
|
10
14
|
[](https://pypi.org/project/smartcli-toolkit/)
|
|
11
15
|
[](https://github.com/dwgx/SmartCLI/actions/workflows/ci.yml)
|
|
16
|
+
[](https://codecov.io/gh/dwgx/SmartCLI)
|
|
12
17
|
[](LICENSE)
|
|
13
18
|
[](https://pypi.org/project/smartcli-toolkit/)
|
|
14
19
|
[](#features)
|
|
15
20
|
[](#install)
|
|
16
21
|
|
|
22
|
+
**Let an AI drive, perceive, and render real terminal programs.** SmartCLI reads
|
|
23
|
+
the actual screen with a `pyte` cell model — not a byte pipe — so it knows which
|
|
24
|
+
menu row is highlighted, presses the right keys, and waits for the screen to
|
|
25
|
+
settle. Below: it drives the real **lazygit** TUI end-to-end (arrow-key
|
|
26
|
+
navigation, opening a commit diff, highlighting a branch) — no script, no mock.
|
|
27
|
+
|
|
28
|
+
<p align="center">
|
|
29
|
+
<img src="showcase/drive-lazygit.gif" alt="SmartCLI driving the real lazygit TUI: navigating panels, opening a commit diff, highlighting a branch" width="700">
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install smartcli-toolkit
|
|
34
|
+
```
|
|
35
|
+
|
|
17
36
|
## What & why
|
|
18
37
|
|
|
19
38
|
SmartCLI is a workspace for terminal work that agents and humans both do: **driving**
|
|
@@ -28,16 +47,12 @@ in place from the checkout.
|
|
|
28
47
|
|
|
29
48
|
## Driving a real TUI
|
|
30
49
|
|
|
31
|
-
SmartCLI driving **lazygit** — a real full-screen curses app —
|
|
32
|
-
perceive → act → confirm loop: it reads the `pyte` cell grid (which
|
|
33
|
-
selected, the alt-screen diff), moves with arrow keys, opens a commit's
|
|
34
|
-
and highlights a branch.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
<p align="center">
|
|
39
|
-
<img src="showcase/drive-lazygit.gif" alt="SmartCLI driving the real lazygit TUI: navigating panels, opening a commit diff, highlighting a branch" width="700">
|
|
40
|
-
</p>
|
|
50
|
+
The demo above is SmartCLI driving **lazygit** — a real full-screen curses app —
|
|
51
|
+
through its perceive → act → confirm loop: it reads the `pyte` cell grid (which
|
|
52
|
+
row is selected, the alt-screen diff), moves with arrow keys, opens a commit's
|
|
53
|
+
diff, and highlights a branch. Captured by driving the actual program in a Linux
|
|
54
|
+
container, not scripted or mocked. A byte-stream matcher like pexpect can't
|
|
55
|
+
perceive "which row is highlighted"; a screen model can.
|
|
41
56
|
|
|
42
57
|
> Honest scope: CI runs a Windows + Linux + macOS matrix. The POSIX pty backend
|
|
43
58
|
> (spawn / read / drive / resize / zombie-free terminate) is verified on Linux
|
|
@@ -124,13 +139,17 @@ set PYTHONIOENCODING=utf-8
|
|
|
124
139
|
Verified dep versions on the dev box (Windows 11, CPython 3.14.6): `pyte` 0.8.2,
|
|
125
140
|
`pywinpty` 3.0.5, `pyfiglet` 1.0.4, `Pillow` 12.2.0, `wcwidth` 0.8.1.
|
|
126
141
|
|
|
142
|
+
**Diagnostics.** `python -m smartcli_core` prints your OS, Python, terminal, PTY
|
|
143
|
+
backend, and dependency versions — run it and paste the output when filing a bug
|
|
144
|
+
(SmartCLI's behavior is very terminal- and platform-sensitive).
|
|
145
|
+
|
|
127
146
|
## Quickstart
|
|
128
147
|
|
|
129
148
|
### cmd-art — terminal visual effects
|
|
130
149
|
|
|
131
150
|
```bash
|
|
132
151
|
cd skills/cmd-art
|
|
133
|
-
python -m fx list # list all
|
|
152
|
+
python -m fx list # list all 22 effects
|
|
134
153
|
python -m fx play donut --seconds 5 # play one effect (bounded)
|
|
135
154
|
python -m fx gallery # one frame of each effect
|
|
136
155
|
python -m fx show --seq "donut:fire:3,plasma::3"
|
|
@@ -157,6 +176,14 @@ python skills/drive-tui/scripts/tui.py snapshot --id <SID>
|
|
|
157
176
|
python skills/drive-tui/scripts/tui.py close --id <SID>
|
|
158
177
|
```
|
|
159
178
|
|
|
179
|
+
Or drive from any MCP client — the same verbs as MCP tools, with the
|
|
180
|
+
per-session token attached automatically:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
pip install "smartcli-toolkit[mcp]"
|
|
184
|
+
python skills/drive-tui/scripts/mcp_server.py # stdio MCP server
|
|
185
|
+
```
|
|
186
|
+
|
|
160
187
|
### As a library
|
|
161
188
|
|
|
162
189
|
The shared core is importable directly:
|
|
@@ -178,9 +205,9 @@ suite, see **[`README-USAGE.md`](README-USAGE.md)**.
|
|
|
178
205
|
## Features
|
|
179
206
|
|
|
180
207
|
**`cmd-art`** (`skills/cmd-art`) — a "living-template" effect engine: an `Effect` ABC +
|
|
181
|
-
`@register` decorator + auto-discovery. **
|
|
208
|
+
`@register` decorator + auto-discovery. **22 effects** (donut, solarsystem, fire, plasma,
|
|
182
209
|
rain, starfield, tunnel, text3d, cube, sphere, boids, life, fireworks, sparkle, decrypt,
|
|
183
|
-
gradient_text, banner_scroll, image2ascii, typewriter) across **8 themes** (mono, fire,
|
|
210
|
+
gradient_text, banner_scroll, image2ascii, typewriter, julia, mandelbrot, perlin) across **8 themes** (mono, fire,
|
|
184
211
|
ocean, synthwave, viridis, pastel, matrix-green, rainbow). Effects are pure frame
|
|
185
212
|
producers; `play` is bounded by default and always restores the terminal.
|
|
186
213
|
|
|
@@ -212,7 +239,7 @@ cross-links. See [`knowledge/INDEX.md`](knowledge/INDEX.md).
|
|
|
212
239
|
```text
|
|
213
240
|
SmartCLI/
|
|
214
241
|
smartcli_core/ shared PTY + pyte engine (importable package)
|
|
215
|
-
skills/cmd-art/ fx effect package and CLI (
|
|
242
|
+
skills/cmd-art/ fx effect package and CLI (22 effects, 8 themes)
|
|
216
243
|
skills/drive-tui/ TUI pattern library and PTY driver CLI (8 recipes)
|
|
217
244
|
skills/tui-ui/ terminal UI layout engine and widgets (15 widgets)
|
|
218
245
|
tools/screenshot/ pyte -> PNG smoke-test harness
|
|
@@ -30,7 +30,7 @@ build-backend = "setuptools.build_meta"
|
|
|
30
30
|
|
|
31
31
|
[project]
|
|
32
32
|
name = "smartcli-toolkit"
|
|
33
|
-
version = "0.1.
|
|
33
|
+
version = "0.1.5"
|
|
34
34
|
description = "Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core)."
|
|
35
35
|
readme = "README.md"
|
|
36
36
|
license = "MIT"
|
|
@@ -96,6 +96,13 @@ art = ["pyfiglet>=1.0.0"] # real FIGlet fonts (else built-in block font)
|
|
|
96
96
|
image = ["Pillow>=10.0.0"] # real raster images (else PNM/procedural demo)
|
|
97
97
|
width = ["wcwidth>=0.2.0"] # authoritative cell width (else unicodedata)
|
|
98
98
|
all = ["pyfiglet>=1.0.0", "Pillow>=10.0.0", "wcwidth>=0.2.0"]
|
|
99
|
+
# MCP server over the drive-tui daemon verb surface (skills/drive-tui/scripts/
|
|
100
|
+
# mcp_server.py). Lets any MCP client drive interactive TUIs. Optional — the
|
|
101
|
+
# CLI and library work without it.
|
|
102
|
+
mcp = ["mcp>=1.0"]
|
|
103
|
+
# Dev-only: multi-process coverage over the script-style suite (see
|
|
104
|
+
# tools/coverage_run.py + .coveragerc). Not needed to run SmartCLI.
|
|
105
|
+
dev = ["coverage[toml]>=7.0"]
|
|
99
106
|
|
|
100
107
|
[tool.setuptools]
|
|
101
108
|
# Only the shared core is a clean installable package (see HONEST NOTE above).
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""python -m smartcli_core — environment diagnostics.
|
|
2
|
+
|
|
3
|
+
Prints the OS, Python, terminal, and dependency facts that most affect how
|
|
4
|
+
SmartCLI behaves, so a bug report can start from ground truth instead of a
|
|
5
|
+
round-trip of "which OS? which terminal? is pywinpty installed?". Inspired by
|
|
6
|
+
`textual diagnose`, which its maintainers called the single most valuable thing
|
|
7
|
+
for cutting issue back-and-forth on a cross-platform, terminal-sensitive tool.
|
|
8
|
+
|
|
9
|
+
Pure/read-only: it imports nothing that spawns a process and never opens a PTY.
|
|
10
|
+
Run it and paste the output into an issue.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ver(mod_name: str, dist_name: str | None = None) -> str:
|
|
20
|
+
"""Best-effort version string for an optional dependency, or a clear absent
|
|
21
|
+
marker. Tries the module's __version__, then importlib.metadata."""
|
|
22
|
+
import importlib
|
|
23
|
+
import importlib.metadata as md
|
|
24
|
+
import importlib.util
|
|
25
|
+
|
|
26
|
+
if importlib.util.find_spec(mod_name) is None:
|
|
27
|
+
return "not installed"
|
|
28
|
+
try:
|
|
29
|
+
mod = importlib.import_module(mod_name)
|
|
30
|
+
v = getattr(mod, "__version__", None)
|
|
31
|
+
if v:
|
|
32
|
+
return str(v)
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
try:
|
|
36
|
+
return md.version(dist_name or mod_name)
|
|
37
|
+
except Exception:
|
|
38
|
+
return "installed (version unknown)"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _section(title: str, rows: list[tuple[str, str]]) -> None:
|
|
42
|
+
print(f"\n{title}")
|
|
43
|
+
print("-" * len(title))
|
|
44
|
+
width = max((len(k) for k, _ in rows), default=0)
|
|
45
|
+
for k, v in rows:
|
|
46
|
+
print(f" {k:<{width}} {v}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> int:
|
|
50
|
+
try:
|
|
51
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
from . import __version__ as core_version
|
|
57
|
+
except Exception:
|
|
58
|
+
core_version = "?"
|
|
59
|
+
|
|
60
|
+
print("=" * 60)
|
|
61
|
+
print("SmartCLI diagnostics")
|
|
62
|
+
print("=" * 60)
|
|
63
|
+
|
|
64
|
+
_section("SmartCLI", [
|
|
65
|
+
("smartcli_core", core_version),
|
|
66
|
+
("import path", os.path.dirname(os.path.abspath(__file__))),
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
_section("Python & OS", [
|
|
70
|
+
("python", sys.version.split()[0]),
|
|
71
|
+
("implementation", platform.python_implementation()),
|
|
72
|
+
("executable", sys.executable),
|
|
73
|
+
("platform", platform.platform()),
|
|
74
|
+
("machine", platform.machine()),
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
# Terminal facts that change SmartCLI's behavior (isatty gates keyboard
|
|
78
|
+
# input; TERM/COLORTERM affect what programs emit; on Windows the PTY path
|
|
79
|
+
# is ConPTY via pywinpty).
|
|
80
|
+
is_win = sys.platform == "win32"
|
|
81
|
+
_section("Terminal", [
|
|
82
|
+
("stdout.isatty()", str(sys.stdout.isatty())),
|
|
83
|
+
("stdin.isatty()", str(sys.stdin.isatty())),
|
|
84
|
+
("TERM", os.environ.get("TERM", "(unset)")),
|
|
85
|
+
("COLORTERM", os.environ.get("COLORTERM", "(unset)")),
|
|
86
|
+
("PYTHONIOENCODING", os.environ.get("PYTHONIOENCODING", "(unset)")),
|
|
87
|
+
("stdout.encoding", getattr(sys.stdout, "encoding", "?") or "?"),
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
_section("PTY backend & deps", [
|
|
91
|
+
("default backend", "ConPTY (pywinpty)" if is_win else "POSIX pty (stdlib)"),
|
|
92
|
+
("pyte", _ver("pyte")),
|
|
93
|
+
("pywinpty", _ver("winpty", "pywinpty") if is_win else "n/a (POSIX)"),
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
_section("Optional extras", [
|
|
97
|
+
("pyfiglet", _ver("pyfiglet")),
|
|
98
|
+
("Pillow (PIL)", _ver("PIL", "Pillow")),
|
|
99
|
+
("wcwidth", _ver("wcwidth")),
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
# Notes that commonly explain reports.
|
|
103
|
+
notes = []
|
|
104
|
+
if is_win and (os.environ.get("PYTHONIOENCODING", "").lower() not in ("utf-8", "utf8")):
|
|
105
|
+
notes.append("Windows without PYTHONIOENCODING=utf-8 — box/CJK glyphs may "
|
|
106
|
+
"crash on a legacy codepage. Set it before running.")
|
|
107
|
+
if _ver("pyte") == "not installed":
|
|
108
|
+
notes.append("pyte is REQUIRED and missing — `pip install smartcli-toolkit`.")
|
|
109
|
+
if is_win and _ver("winpty", "pywinpty") == "not installed":
|
|
110
|
+
notes.append("pywinpty missing on Windows — the ConPTY backend can't run.")
|
|
111
|
+
if notes:
|
|
112
|
+
print("\nNotes")
|
|
113
|
+
print("-----")
|
|
114
|
+
for n in notes:
|
|
115
|
+
print(f" ! {n}")
|
|
116
|
+
|
|
117
|
+
print()
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
sys.exit(main())
|
|
@@ -74,6 +74,32 @@ class ScreenModel:
|
|
|
74
74
|
# feed raise) is not silently indistinguishable from the occasional
|
|
75
75
|
# garbled byte run it is meant to tolerate.
|
|
76
76
|
self.feed_errors = 0
|
|
77
|
+
# Device-query replies (DSR-CPR "ESC[6n", DA "ESC[c") that pyte generates
|
|
78
|
+
# while parsing. pyte routes them to Screen.write_process_input, which is a
|
|
79
|
+
# no-op by default — so a program that SYNCHRONOUSLY waits for a cursor-
|
|
80
|
+
# position report can stall/degrade because nothing answers. We capture
|
|
81
|
+
# them here (pyte builds the correct reply from its own cursor/attrs) and
|
|
82
|
+
# PtySession.pump() writes them back to the PTY. See drain_replies().
|
|
83
|
+
self._reply_buf = bytearray()
|
|
84
|
+
self.screen.write_process_input = self._collect_reply # type: ignore[method-assign]
|
|
85
|
+
|
|
86
|
+
def _collect_reply(self, data) -> None:
|
|
87
|
+
"""pyte hands us the bytes/str it wants sent back to the process."""
|
|
88
|
+
if isinstance(data, str):
|
|
89
|
+
data = data.encode("utf-8", "replace")
|
|
90
|
+
self._reply_buf.extend(data)
|
|
91
|
+
|
|
92
|
+
def drain_replies(self) -> bytes:
|
|
93
|
+
"""Return and clear any pending device-query replies pyte generated.
|
|
94
|
+
|
|
95
|
+
PtySession.pump() calls this after feed() and writes the result back to
|
|
96
|
+
the PTY, so DSR-CPR / DA queries from the driven program get answered.
|
|
97
|
+
"""
|
|
98
|
+
if not self._reply_buf:
|
|
99
|
+
return b""
|
|
100
|
+
out = bytes(self._reply_buf)
|
|
101
|
+
self._reply_buf.clear()
|
|
102
|
+
return out
|
|
77
103
|
|
|
78
104
|
# -- feeding -----------------------------------------------------------
|
|
79
105
|
|
|
@@ -177,10 +177,24 @@ class PtySession:
|
|
|
177
177
|
# -- io ----------------------------------------------------------------
|
|
178
178
|
|
|
179
179
|
def pump(self) -> bytes:
|
|
180
|
-
"""Read whatever is available and feed it into the screen. Returns bytes.
|
|
180
|
+
"""Read whatever is available and feed it into the screen. Returns bytes.
|
|
181
|
+
|
|
182
|
+
After feeding, answer any device-status/attribute queries the program
|
|
183
|
+
emitted (DSR-CPR ``ESC[6n``, DA ``ESC[c``): pyte builds the correct reply
|
|
184
|
+
from its own cursor/attr state, and we write it back to the PTY. Without
|
|
185
|
+
this, a program that synchronously waits for a cursor-position report can
|
|
186
|
+
stall or fall back to a degraded mode. Best-effort: a write failure here
|
|
187
|
+
must never break perception.
|
|
188
|
+
"""
|
|
181
189
|
data = self.backend.read_nonblocking()
|
|
182
190
|
if data:
|
|
183
191
|
self.model.feed(data)
|
|
192
|
+
reply = self.model.drain_replies()
|
|
193
|
+
if reply:
|
|
194
|
+
try:
|
|
195
|
+
self.backend.write(reply)
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
184
198
|
return data
|
|
185
199
|
|
|
186
200
|
def send_text(self, text: str) -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: smartcli-toolkit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core).
|
|
5
5
|
Author: dwgx
|
|
6
6
|
License-Expression: MIT
|
|
@@ -38,9 +38,17 @@ Provides-Extra: all
|
|
|
38
38
|
Requires-Dist: pyfiglet>=1.0.0; extra == "all"
|
|
39
39
|
Requires-Dist: Pillow>=10.0.0; extra == "all"
|
|
40
40
|
Requires-Dist: wcwidth>=0.2.0; extra == "all"
|
|
41
|
+
Provides-Extra: mcp
|
|
42
|
+
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: coverage[toml]>=7.0; extra == "dev"
|
|
41
45
|
Dynamic: license-file
|
|
42
46
|
|
|
43
47
|
<!-- Language: English | [简体中文](docs/i18n/README.zh-Hans.md) | [繁體中文](docs/i18n/README.zh-Hant.md) | [日本語](docs/i18n/README.ja.md) | [한국어](docs/i18n/README.ko.md) -->
|
|
48
|
+
<!-- mcp-name: io.github.dwgx/smartcli -->
|
|
49
|
+
<!-- ^ MCP Registry PyPI ownership marker: this string must appear in the
|
|
50
|
+
published package's README (= PyPI description) and match server.json's
|
|
51
|
+
"name". Do not remove or change without updating server.json. -->
|
|
44
52
|
|
|
45
53
|
# SmartCLI
|
|
46
54
|
|
|
@@ -51,11 +59,26 @@ Dynamic: license-file
|
|
|
51
59
|
[](https://pypi.org/project/smartcli-toolkit/)
|
|
52
60
|
[](https://pypi.org/project/smartcli-toolkit/)
|
|
53
61
|
[](https://github.com/dwgx/SmartCLI/actions/workflows/ci.yml)
|
|
62
|
+
[](https://codecov.io/gh/dwgx/SmartCLI)
|
|
54
63
|
[](LICENSE)
|
|
55
64
|
[](https://pypi.org/project/smartcli-toolkit/)
|
|
56
65
|
[](#features)
|
|
57
66
|
[](#install)
|
|
58
67
|
|
|
68
|
+
**Let an AI drive, perceive, and render real terminal programs.** SmartCLI reads
|
|
69
|
+
the actual screen with a `pyte` cell model — not a byte pipe — so it knows which
|
|
70
|
+
menu row is highlighted, presses the right keys, and waits for the screen to
|
|
71
|
+
settle. Below: it drives the real **lazygit** TUI end-to-end (arrow-key
|
|
72
|
+
navigation, opening a commit diff, highlighting a branch) — no script, no mock.
|
|
73
|
+
|
|
74
|
+
<p align="center">
|
|
75
|
+
<img src="showcase/drive-lazygit.gif" alt="SmartCLI driving the real lazygit TUI: navigating panels, opening a commit diff, highlighting a branch" width="700">
|
|
76
|
+
</p>
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install smartcli-toolkit
|
|
80
|
+
```
|
|
81
|
+
|
|
59
82
|
## What & why
|
|
60
83
|
|
|
61
84
|
SmartCLI is a workspace for terminal work that agents and humans both do: **driving**
|
|
@@ -70,16 +93,12 @@ in place from the checkout.
|
|
|
70
93
|
|
|
71
94
|
## Driving a real TUI
|
|
72
95
|
|
|
73
|
-
SmartCLI driving **lazygit** — a real full-screen curses app —
|
|
74
|
-
perceive → act → confirm loop: it reads the `pyte` cell grid (which
|
|
75
|
-
selected, the alt-screen diff), moves with arrow keys, opens a commit's
|
|
76
|
-
and highlights a branch.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
<p align="center">
|
|
81
|
-
<img src="showcase/drive-lazygit.gif" alt="SmartCLI driving the real lazygit TUI: navigating panels, opening a commit diff, highlighting a branch" width="700">
|
|
82
|
-
</p>
|
|
96
|
+
The demo above is SmartCLI driving **lazygit** — a real full-screen curses app —
|
|
97
|
+
through its perceive → act → confirm loop: it reads the `pyte` cell grid (which
|
|
98
|
+
row is selected, the alt-screen diff), moves with arrow keys, opens a commit's
|
|
99
|
+
diff, and highlights a branch. Captured by driving the actual program in a Linux
|
|
100
|
+
container, not scripted or mocked. A byte-stream matcher like pexpect can't
|
|
101
|
+
perceive "which row is highlighted"; a screen model can.
|
|
83
102
|
|
|
84
103
|
> Honest scope: CI runs a Windows + Linux + macOS matrix. The POSIX pty backend
|
|
85
104
|
> (spawn / read / drive / resize / zombie-free terminate) is verified on Linux
|
|
@@ -166,13 +185,17 @@ set PYTHONIOENCODING=utf-8
|
|
|
166
185
|
Verified dep versions on the dev box (Windows 11, CPython 3.14.6): `pyte` 0.8.2,
|
|
167
186
|
`pywinpty` 3.0.5, `pyfiglet` 1.0.4, `Pillow` 12.2.0, `wcwidth` 0.8.1.
|
|
168
187
|
|
|
188
|
+
**Diagnostics.** `python -m smartcli_core` prints your OS, Python, terminal, PTY
|
|
189
|
+
backend, and dependency versions — run it and paste the output when filing a bug
|
|
190
|
+
(SmartCLI's behavior is very terminal- and platform-sensitive).
|
|
191
|
+
|
|
169
192
|
## Quickstart
|
|
170
193
|
|
|
171
194
|
### cmd-art — terminal visual effects
|
|
172
195
|
|
|
173
196
|
```bash
|
|
174
197
|
cd skills/cmd-art
|
|
175
|
-
python -m fx list # list all
|
|
198
|
+
python -m fx list # list all 22 effects
|
|
176
199
|
python -m fx play donut --seconds 5 # play one effect (bounded)
|
|
177
200
|
python -m fx gallery # one frame of each effect
|
|
178
201
|
python -m fx show --seq "donut:fire:3,plasma::3"
|
|
@@ -199,6 +222,14 @@ python skills/drive-tui/scripts/tui.py snapshot --id <SID>
|
|
|
199
222
|
python skills/drive-tui/scripts/tui.py close --id <SID>
|
|
200
223
|
```
|
|
201
224
|
|
|
225
|
+
Or drive from any MCP client — the same verbs as MCP tools, with the
|
|
226
|
+
per-session token attached automatically:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
pip install "smartcli-toolkit[mcp]"
|
|
230
|
+
python skills/drive-tui/scripts/mcp_server.py # stdio MCP server
|
|
231
|
+
```
|
|
232
|
+
|
|
202
233
|
### As a library
|
|
203
234
|
|
|
204
235
|
The shared core is importable directly:
|
|
@@ -220,9 +251,9 @@ suite, see **[`README-USAGE.md`](README-USAGE.md)**.
|
|
|
220
251
|
## Features
|
|
221
252
|
|
|
222
253
|
**`cmd-art`** (`skills/cmd-art`) — a "living-template" effect engine: an `Effect` ABC +
|
|
223
|
-
`@register` decorator + auto-discovery. **
|
|
254
|
+
`@register` decorator + auto-discovery. **22 effects** (donut, solarsystem, fire, plasma,
|
|
224
255
|
rain, starfield, tunnel, text3d, cube, sphere, boids, life, fireworks, sparkle, decrypt,
|
|
225
|
-
gradient_text, banner_scroll, image2ascii, typewriter) across **8 themes** (mono, fire,
|
|
256
|
+
gradient_text, banner_scroll, image2ascii, typewriter, julia, mandelbrot, perlin) across **8 themes** (mono, fire,
|
|
226
257
|
ocean, synthwave, viridis, pastel, matrix-green, rainbow). Effects are pure frame
|
|
227
258
|
producers; `play` is bounded by default and always restores the terminal.
|
|
228
259
|
|
|
@@ -254,7 +285,7 @@ cross-links. See [`knowledge/INDEX.md`](knowledge/INDEX.md).
|
|
|
254
285
|
```text
|
|
255
286
|
SmartCLI/
|
|
256
287
|
smartcli_core/ shared PTY + pyte engine (importable package)
|
|
257
|
-
skills/cmd-art/ fx effect package and CLI (
|
|
288
|
+
skills/cmd-art/ fx effect package and CLI (22 effects, 8 themes)
|
|
258
289
|
skills/drive-tui/ TUI pattern library and PTY driver CLI (8 recipes)
|
|
259
290
|
skills/tui-ui/ terminal UI layout engine and widgets (15 widgets)
|
|
260
291
|
tools/screenshot/ pyte -> PNG smoke-test harness
|
|
@@ -2,6 +2,7 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
smartcli_core/__init__.py
|
|
5
|
+
smartcli_core/__main__.py
|
|
5
6
|
smartcli_core/pty_backend.py
|
|
6
7
|
smartcli_core/py.typed
|
|
7
8
|
smartcli_core/readiness.py
|
|
@@ -13,8 +14,11 @@ smartcli_toolkit.egg-info/SOURCES.txt
|
|
|
13
14
|
smartcli_toolkit.egg-info/dependency_links.txt
|
|
14
15
|
smartcli_toolkit.egg-info/requires.txt
|
|
15
16
|
smartcli_toolkit.egg-info/top_level.txt
|
|
17
|
+
tests/test_char_width.py
|
|
18
|
+
tests/test_cpr_reply.py
|
|
16
19
|
tests/test_degenerate_inputs.py
|
|
17
20
|
tests/test_doc_counts.py
|
|
18
21
|
tests/test_fx_contract.py
|
|
22
|
+
tests/test_golden_frames.py
|
|
19
23
|
tests/test_readiness.py
|
|
20
24
|
tests/test_vendor_sync.py
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""test_char_width.py — width function: default stability + the new knobs.
|
|
3
|
+
|
|
4
|
+
Character cell width is a coordination problem (terminals ship different Unicode
|
|
5
|
+
DBs). ui.core.width/char_width gained two optional knobs — unicode_version and
|
|
6
|
+
ambiguous_wide — so a caller can pin the answer to its terminal. This locks:
|
|
7
|
+
* defaults are byte-identical to the old behavior (so golden/fx baselines
|
|
8
|
+
don't move),
|
|
9
|
+
* ambiguous_wide flips East-Asian Ambiguous glyphs 1<->2 without touching
|
|
10
|
+
unambiguous ones,
|
|
11
|
+
* CJK / emoji / ZWJ / combining widths are unaffected by the knob,
|
|
12
|
+
* pinning unicode_version doesn't crash.
|
|
13
|
+
|
|
14
|
+
Pure/in-memory: imports the ui package, computes widths. No process, no PTY.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
22
|
+
sys.path.insert(0, str(ROOT / "skills" / "tui-ui"))
|
|
23
|
+
|
|
24
|
+
from ui.core import char_width, width # noqa: E402
|
|
25
|
+
|
|
26
|
+
failures = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check(cond: bool, label: str, detail: str = "") -> None:
|
|
30
|
+
global failures
|
|
31
|
+
if not cond:
|
|
32
|
+
failures += 1
|
|
33
|
+
print(f"{'PASS' if cond else 'FAIL'} {label}" + (f" -- {detail}" if detail else ""))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# East-Asian Ambiguous sample chars (unicodedata.east_asian_width == 'A').
|
|
37
|
+
AMBIGUOUS = ["§", "±", "…", "○", "×"]
|
|
38
|
+
# Unambiguous references that must never change with the knob.
|
|
39
|
+
CJK = "你好" # each 2 cells
|
|
40
|
+
ASCII = "hello" # each 1 cell
|
|
41
|
+
EMOJI = "😀" # 2 cells
|
|
42
|
+
ZWJ = "" # 0 cells
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_defaults_unchanged() -> None:
|
|
46
|
+
# Default: ambiguous is narrow (1), matching the pre-change behavior.
|
|
47
|
+
for c in AMBIGUOUS:
|
|
48
|
+
check(char_width(c) == 1, f"default: ambiguous U+{ord(c):04X} is 1 cell",
|
|
49
|
+
detail=str(char_width(c)))
|
|
50
|
+
check(width(ASCII) == 5, "default: 'hello' is 5", detail=str(width(ASCII)))
|
|
51
|
+
check(width(CJK) == 4, "default: 2 CJK chars are 4", detail=str(width(CJK)))
|
|
52
|
+
check(char_width(EMOJI) == 2, "default: emoji is 2")
|
|
53
|
+
check(char_width(ZWJ) == 0, "default: ZWJ is 0")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_ambiguous_wide_knob() -> None:
|
|
57
|
+
# With the CJK-locale knob, ambiguous glyphs count as 2.
|
|
58
|
+
for c in AMBIGUOUS:
|
|
59
|
+
check(char_width(c, ambiguous_wide=True) == 2,
|
|
60
|
+
f"ambiguous_wide: U+{ord(c):04X} is 2 cells",
|
|
61
|
+
detail=str(char_width(c, ambiguous_wide=True)))
|
|
62
|
+
# A whole string of ambiguous chars scales.
|
|
63
|
+
s = "".join(AMBIGUOUS)
|
|
64
|
+
check(width(s, ambiguous_wide=True) == 2 * len(AMBIGUOUS),
|
|
65
|
+
"ambiguous_wide: string doubles", detail=str(width(s, ambiguous_wide=True)))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_knob_leaves_unambiguous_alone() -> None:
|
|
69
|
+
# The knob must ONLY affect ambiguous chars — CJK/ASCII/emoji/ZWJ unchanged.
|
|
70
|
+
check(width(ASCII, ambiguous_wide=True) == 5, "knob: ASCII still 5")
|
|
71
|
+
check(width(CJK, ambiguous_wide=True) == 4, "knob: CJK still 4")
|
|
72
|
+
check(char_width(EMOJI, ambiguous_wide=True) == 2, "knob: emoji still 2")
|
|
73
|
+
check(char_width(ZWJ, ambiguous_wide=True) == 0, "knob: ZWJ still 0")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_unicode_version_pin() -> None:
|
|
77
|
+
# Pinning a version must not crash and must return a sane width for ASCII.
|
|
78
|
+
try:
|
|
79
|
+
w = width("hello", unicode_version="9.0.0")
|
|
80
|
+
check(w == 5, "unicode_version='9.0.0' gives 'hello'==5", detail=str(w))
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
check(False, "unicode_version pin did not crash", detail=repr(exc))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main() -> int:
|
|
86
|
+
test_defaults_unchanged()
|
|
87
|
+
test_ambiguous_wide_knob()
|
|
88
|
+
test_knob_leaves_unambiguous_alone()
|
|
89
|
+
test_unicode_version_pin()
|
|
90
|
+
print()
|
|
91
|
+
if failures:
|
|
92
|
+
print(f"{failures} FAILURE(S)")
|
|
93
|
+
return 1
|
|
94
|
+
print("ALL PASS")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
sys.exit(main())
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""test_cpr_reply.py — device-query auto-answer regression (DSR-CPR / DA).
|
|
3
|
+
|
|
4
|
+
A program that emits a cursor-position query (``ESC[6n``) or a device-attributes
|
|
5
|
+
query (``ESC[c``) and then SYNCHRONOUSLY waits for the reply will stall or fall
|
|
6
|
+
back to a degraded mode if nothing answers. pyte generates the correct reply
|
|
7
|
+
(from its own cursor/attr state) and routes it to Screen.write_process_input;
|
|
8
|
+
SmartCLI now captures that and PtySession.pump() writes it back to the PTY.
|
|
9
|
+
|
|
10
|
+
This locks that wiring. Pure/in-memory: a fake backend feeds the query bytes and
|
|
11
|
+
captures what gets written back — no PTY, no process. Mutation check: if pump()
|
|
12
|
+
stops writing the reply (or ScreenModel stops capturing it), the writes-back
|
|
13
|
+
assertions fail.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
21
|
+
sys.path.insert(0, str(ROOT))
|
|
22
|
+
|
|
23
|
+
from smartcli_core import PtySession, ScreenModel # noqa: E402
|
|
24
|
+
from smartcli_core.pty_backend import PtyBackend # noqa: E402
|
|
25
|
+
|
|
26
|
+
failures = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check(cond: bool, label: str, detail: str = "") -> None:
|
|
30
|
+
global failures
|
|
31
|
+
if not cond:
|
|
32
|
+
failures += 1
|
|
33
|
+
print(f"{'PASS' if cond else 'FAIL'} {label}" + (f" -- {detail}" if detail else ""))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FakeBackend(PtyBackend):
|
|
37
|
+
"""A minimal backend: hands out queued bytes on read, captures writes."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._to_read: list[bytes] = []
|
|
41
|
+
self.written = bytearray()
|
|
42
|
+
self._alive = True
|
|
43
|
+
|
|
44
|
+
def queue(self, data: bytes) -> None:
|
|
45
|
+
self._to_read.append(data)
|
|
46
|
+
|
|
47
|
+
# PtyBackend interface --------------------------------------------------
|
|
48
|
+
def spawn(self, cmd, cols, rows) -> None: # noqa: D401
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def read_nonblocking(self, timeout: float = 0.0) -> bytes:
|
|
52
|
+
return self._to_read.pop(0) if self._to_read else b""
|
|
53
|
+
|
|
54
|
+
def write(self, data: bytes) -> None:
|
|
55
|
+
self.written.extend(data)
|
|
56
|
+
|
|
57
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def is_alive(self) -> bool:
|
|
61
|
+
return self._alive
|
|
62
|
+
|
|
63
|
+
def terminate(self) -> None:
|
|
64
|
+
self._alive = False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_screenmodel_captures_replies() -> None:
|
|
68
|
+
m = ScreenModel(80, 24)
|
|
69
|
+
# Move cursor to row 3, col 5 (1-based), then ask for a cursor-position report.
|
|
70
|
+
m.feed(b"\x1b[3;5H\x1b[6n")
|
|
71
|
+
reply = m.drain_replies()
|
|
72
|
+
check(reply == b"\x1b[3;5R", "ScreenModel answers DSR-CPR from its cursor",
|
|
73
|
+
detail=repr(reply))
|
|
74
|
+
# drain is one-shot.
|
|
75
|
+
check(m.drain_replies() == b"", "drain_replies clears after read")
|
|
76
|
+
# Device-attributes query.
|
|
77
|
+
m.feed(b"\x1b[c")
|
|
78
|
+
check(m.drain_replies() == b"\x1b[?6c", "ScreenModel answers DA1")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_pump_writes_reply_back() -> None:
|
|
82
|
+
be = FakeBackend()
|
|
83
|
+
sess = PtySession(cols=80, rows=24, backend=be)
|
|
84
|
+
# Program positions the cursor and queries it, all in one read.
|
|
85
|
+
be.queue(b"hello\x1b[2;3H\x1b[6n")
|
|
86
|
+
data = sess.pump()
|
|
87
|
+
check(b"hello" in data, "pump returns the screen bytes it read")
|
|
88
|
+
check(be.written == b"\x1b[2;3R", "pump wrote the CPR reply back to the PTY",
|
|
89
|
+
detail=repr(bytes(be.written)))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_no_query_no_write() -> None:
|
|
93
|
+
be = FakeBackend()
|
|
94
|
+
sess = PtySession(cols=80, rows=24, backend=be)
|
|
95
|
+
be.queue(b"just some text, no queries")
|
|
96
|
+
sess.pump()
|
|
97
|
+
check(be.written == b"", "no device query -> nothing written back",
|
|
98
|
+
detail=repr(bytes(be.written)))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_reply_survives_resize() -> None:
|
|
102
|
+
# pyte.Screen.resize is in-place (doesn't rebuild the screen or reset
|
|
103
|
+
# write_process_input), so the reply binding must survive a resize.
|
|
104
|
+
m = ScreenModel(80, 24)
|
|
105
|
+
m.resize(100, 30)
|
|
106
|
+
m.feed(b"\x1b[2;2H\x1b[6n")
|
|
107
|
+
check(m.drain_replies() == b"\x1b[2;2R", "CPR still answered after resize")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_no_feedback_loop() -> None:
|
|
111
|
+
# Our own replies, fed back in (as an echoing PTY might), must NOT generate a
|
|
112
|
+
# second reply — otherwise a CPR/DA answer could amplify into a loop. The
|
|
113
|
+
# reply terminators (R / ?..c / ..n status) are not queries.
|
|
114
|
+
m = ScreenModel(80, 24)
|
|
115
|
+
m.feed(b"\x1b[3;5R") # a CPR reply
|
|
116
|
+
m.feed(b"\x1b[?6c") # a DA reply
|
|
117
|
+
m.feed(b"\x1b[0n") # a DSR status reply
|
|
118
|
+
check(m.drain_replies() == b"", "replies fed back produce no second reply")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main() -> int:
|
|
122
|
+
test_screenmodel_captures_replies()
|
|
123
|
+
test_pump_writes_reply_back()
|
|
124
|
+
test_no_query_no_write()
|
|
125
|
+
test_reply_survives_resize()
|
|
126
|
+
test_no_feedback_loop()
|
|
127
|
+
print()
|
|
128
|
+
if failures:
|
|
129
|
+
print(f"{failures} FAILURE(S)")
|
|
130
|
+
return 1
|
|
131
|
+
print("ALL PASS")
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
sys.exit(main())
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""test_golden_frames.py — golden-frame snapshot regression for tui-ui widgets.
|
|
3
|
+
|
|
4
|
+
Every registered widget is rendered to a DETERMINISTIC ANSI frame (its
|
|
5
|
+
``sample(theme)`` composed into a fixed-size ``Page``, exactly the path
|
|
6
|
+
``python -m ui demo`` uses) and compared byte-for-byte against a committed
|
|
7
|
+
baseline in ``tests/golden/<key>.txt``. This locks the 15 widgets against silent
|
|
8
|
+
visual regressions — today only degenerate-input crashes and the fx frame
|
|
9
|
+
contract are guarded; widget *output* was not.
|
|
10
|
+
|
|
11
|
+
Pure/in-memory: it imports the ui package and renders strings, never spawns a
|
|
12
|
+
process. Deterministic: widgets carry no random/time dependency (verified), and
|
|
13
|
+
the test renders each widget TWICE and fails if the two disagree, so a
|
|
14
|
+
non-deterministic widget can never silently bake flakiness into a baseline.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python tests/test_golden_frames.py # check against baselines (exit 1 on drift)
|
|
18
|
+
python tests/test_golden_frames.py --update # (re)write baselines from current output
|
|
19
|
+
python tests/test_golden_frames.py badge card # limit to named widgets
|
|
20
|
+
|
|
21
|
+
The baselines are meant to be reviewed in the diff: when a widget's look changes
|
|
22
|
+
on purpose, run --update and the PR shows exactly which cells moved.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
30
|
+
sys.path.insert(0, str(ROOT / "skills" / "tui-ui"))
|
|
31
|
+
|
|
32
|
+
# Force UTF-8 stdout: baselines and diffs contain box-drawing + CJK, which crash
|
|
33
|
+
# on a legacy Windows codepage (CP936) otherwise.
|
|
34
|
+
try:
|
|
35
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
from ui import registry # noqa: E402
|
|
40
|
+
from ui.box import Box # noqa: E402
|
|
41
|
+
from ui.core import get_theme # noqa: E402
|
|
42
|
+
from ui.layout import Page # noqa: E402
|
|
43
|
+
|
|
44
|
+
GOLDEN_DIR = ROOT / "tests" / "golden"
|
|
45
|
+
|
|
46
|
+
# Fixed render parameters — must stay constant or every baseline shifts. Chosen
|
|
47
|
+
# wide/tall enough that no widget sample clips at its default.
|
|
48
|
+
WIDTH, HEIGHT = 60, 12
|
|
49
|
+
THEME = "dashboard"
|
|
50
|
+
|
|
51
|
+
results: list[tuple[str, bool, str]] = []
|
|
52
|
+
skipped: list[str] = []
|
|
53
|
+
|
|
54
|
+
# Widgets whose rendered frame depends on an OPTIONAL dependency: their output
|
|
55
|
+
# differs between "dep present" (real render) and "dep absent" (graceful stdlib
|
|
56
|
+
# fallback), so a single baseline can't cover both. When the dep is missing we
|
|
57
|
+
# SKIP the widget rather than fail — the baseline is only meaningful when the dep
|
|
58
|
+
# matches the environment that generated it. (banner -> pyfiglet FIGlet fonts;
|
|
59
|
+
# the baseline is the pyfiglet render. CI installs .[all] so it's exercised.)
|
|
60
|
+
OPTIONAL_DEP = {"banner": "pyfiglet"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _dep_available(mod: str) -> bool:
|
|
64
|
+
import importlib.util
|
|
65
|
+
return importlib.util.find_spec(mod) is not None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def record(name: str, ok: bool, detail: str = "") -> None:
|
|
69
|
+
results.append((name, ok, detail))
|
|
70
|
+
print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" -- {detail}" if detail else ""))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def note_skip(name: str, detail: str) -> None:
|
|
74
|
+
skipped.append(name)
|
|
75
|
+
print(f"SKIP {name} -- {detail}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def render_widget(cls) -> str:
|
|
79
|
+
"""Render ONE widget to a deterministic ANSI frame — mirrors ui.cli.cmd_demo:
|
|
80
|
+
sample(theme) -> Box -> Page.to_ansi()."""
|
|
81
|
+
theme = get_theme(THEME)
|
|
82
|
+
widget = cls.sample(theme)
|
|
83
|
+
page = Page(Box(content=widget, padding=(0, 0), bg=theme.bg),
|
|
84
|
+
width=WIDTH, height=HEIGHT, bg=theme.bg)
|
|
85
|
+
return page.to_ansi()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _baseline_path(key: str) -> Path:
|
|
89
|
+
return GOLDEN_DIR / f"{key}.txt"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _read_lf(path: Path) -> str:
|
|
93
|
+
"""Read a baseline, normalizing any CRLF to LF so the comparison is
|
|
94
|
+
platform-independent (a Windows checkout may present CRLF; the rendered
|
|
95
|
+
frame is always LF)."""
|
|
96
|
+
return path.read_text(encoding="utf-8").replace("\r\n", "\n")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _first_diff(a: str, b: str) -> str:
|
|
100
|
+
"""Human-pointable description of where two frames first differ."""
|
|
101
|
+
al, bl = a.splitlines(), b.splitlines()
|
|
102
|
+
if len(al) != len(bl):
|
|
103
|
+
return f"line count {len(al)} != baseline {len(bl)}"
|
|
104
|
+
for i, (x, y) in enumerate(zip(al, bl)):
|
|
105
|
+
if x != y:
|
|
106
|
+
col = next((j for j, (cx, cy) in enumerate(zip(x, y)) if cx != cy),
|
|
107
|
+
min(len(x), len(y)))
|
|
108
|
+
return f"line {i} differs at col {col}"
|
|
109
|
+
return "identical length, content differs (trailing?)"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_widget(cls, update: bool) -> None:
|
|
113
|
+
key = cls.key
|
|
114
|
+
# If this widget's render depends on an optional dep that's absent, its
|
|
115
|
+
# fallback output won't match the baseline (generated with the dep present).
|
|
116
|
+
# Skip rather than fail — a missing optional dep is not a regression.
|
|
117
|
+
dep = OPTIONAL_DEP.get(key)
|
|
118
|
+
if dep and not _dep_available(dep) and not update:
|
|
119
|
+
note_skip(f"golden {key}", f"optional dep '{dep}' absent — baseline needs it")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# Determinism gate: render twice, must be byte-identical. A widget that
|
|
123
|
+
# depends on time/random would trip here instead of baking flakiness in.
|
|
124
|
+
frame1 = render_widget(cls)
|
|
125
|
+
frame2 = render_widget(cls)
|
|
126
|
+
if frame1 != frame2:
|
|
127
|
+
record(f"golden {key}", False,
|
|
128
|
+
f"NON-DETERMINISTIC render ({_first_diff(frame1, frame2)}) — "
|
|
129
|
+
"fix the widget or seed it before baselining")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
path = _baseline_path(key)
|
|
133
|
+
if update:
|
|
134
|
+
GOLDEN_DIR.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
existed = path.exists()
|
|
136
|
+
old = _read_lf(path) if existed else None
|
|
137
|
+
# Write with LF newlines explicitly (newline="") so a Windows run doesn't
|
|
138
|
+
# bake CRLF into the baseline — the rendered frame uses \n, and git
|
|
139
|
+
# normalizes these files to LF (.gitattributes), so the baseline must be
|
|
140
|
+
# LF on every platform or the check would drift CRLF-vs-LF across OSes.
|
|
141
|
+
with open(path, "w", encoding="utf-8", newline="") as fh:
|
|
142
|
+
fh.write(frame1)
|
|
143
|
+
state = "unchanged" if old == frame1 else ("updated" if existed else "created")
|
|
144
|
+
record(f"golden {key}", True, f"baseline {state}")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
if not path.exists():
|
|
148
|
+
record(f"golden {key}", False,
|
|
149
|
+
f"NO baseline at {path.relative_to(ROOT)} — run with --update")
|
|
150
|
+
return
|
|
151
|
+
baseline = _read_lf(path)
|
|
152
|
+
if frame1 == baseline:
|
|
153
|
+
record(f"golden {key}", True, f"matches baseline ({len(frame1)} bytes)")
|
|
154
|
+
else:
|
|
155
|
+
record(f"golden {key}", False,
|
|
156
|
+
f"DRIFT vs baseline: {_first_diff(frame1, baseline)} "
|
|
157
|
+
"(intentional? run --update and review the diff)")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main(argv: list[str]) -> int:
|
|
161
|
+
update = "--update" in argv
|
|
162
|
+
only = {a for a in argv if not a.startswith("-")}
|
|
163
|
+
|
|
164
|
+
registry.load_all()
|
|
165
|
+
for mod, _tb in registry.load_errors():
|
|
166
|
+
print(f"warning: widget module {mod} failed to import", file=sys.stderr)
|
|
167
|
+
|
|
168
|
+
widgets = [c for c in registry.all_widgets() if not only or c.key in only]
|
|
169
|
+
if not widgets:
|
|
170
|
+
print(f"error: no widgets matched {only or '(all)'}", file=sys.stderr)
|
|
171
|
+
return 2
|
|
172
|
+
|
|
173
|
+
for cls in widgets:
|
|
174
|
+
check_widget(cls, update)
|
|
175
|
+
|
|
176
|
+
failed = [r for r in results if not r[1]]
|
|
177
|
+
skip_note = f", {len(skipped)} skipped (optional dep absent)" if skipped else ""
|
|
178
|
+
print(f"\n{len(results) - len(failed)}/{len(results)} widgets "
|
|
179
|
+
f"{'baselined' if update else 'match golden frames'} "
|
|
180
|
+
f"(size {WIDTH}x{HEIGHT}, theme {THEME}){skip_note}")
|
|
181
|
+
if failed and not update:
|
|
182
|
+
print("golden-frame drift — review, then `--update` if intended:")
|
|
183
|
+
for name, _ok, detail in failed:
|
|
184
|
+
print(f" - {name}: {detail}")
|
|
185
|
+
return 1 if (failed and not update) else 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
sys.exit(main(sys.argv[1:]))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{smartcli_toolkit-0.1.3 → smartcli_toolkit-0.1.5}/smartcli_toolkit.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|