anyshell 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.
- anyshell/__init__.py +27 -0
- anyshell/__main__.py +74 -0
- anyshell/commands.py +395 -0
- anyshell/editor.py +122 -0
- anyshell/engine.py +422 -0
- anyshell/filesystem.py +349 -0
- anyshell/frontend.py +535 -0
- anyshell/pager.py +203 -0
- anyshell/presentation.py +55 -0
- anyshell/py.typed +0 -0
- anyshell-0.1.0.dist-info/METADATA +337 -0
- anyshell-0.1.0.dist-info/RECORD +15 -0
- anyshell-0.1.0.dist-info/WHEEL +4 -0
- anyshell-0.1.0.dist-info/entry_points.txt +3 -0
- anyshell-0.1.0.dist-info/licenses/LICENSE +21 -0
anyshell/pager.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Read-only paging inside the Shell widget, without a terminal subprocess."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import anyio
|
|
6
|
+
from anytext import on
|
|
7
|
+
from anytext.app import ComposeResult
|
|
8
|
+
from anytext.binding import Binding
|
|
9
|
+
from anytext.containers import Horizontal, Vertical
|
|
10
|
+
from anytext.reactive import var
|
|
11
|
+
from anytext.widgets import Input, RichLog, Static
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from .engine import PageRequest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PagerLog(RichLog, can_focus=False):
|
|
18
|
+
"""Keep keyboard focus on the pager and expose mouse scrolling."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PagerSearchInput(Input):
|
|
22
|
+
"""A temporary regex search field, separate from the shell command input."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Pager(Vertical, can_focus=True):
|
|
26
|
+
BINDINGS = [
|
|
27
|
+
Binding("space,pagedown", "next_page", "Next page", priority=True),
|
|
28
|
+
Binding("b,pageup", "previous_page", "Previous page", priority=True),
|
|
29
|
+
Binding("enter,down", "next_line", show=False, priority=True),
|
|
30
|
+
Binding("up", "previous_line", show=False, priority=True),
|
|
31
|
+
Binding("home,g", "first_page", show=False, priority=True),
|
|
32
|
+
Binding("end,G", "last_page", show=False, priority=True),
|
|
33
|
+
Binding("q,escape", "close_pager", show=False, priority=True),
|
|
34
|
+
Binding("ctrl+q", "force_close", "Close", priority=True),
|
|
35
|
+
Binding("slash", "search_forward", "Search", priority=True),
|
|
36
|
+
Binding("question_mark", "search_backward", show=False, priority=True),
|
|
37
|
+
Binding("n", "next_match", "Next match", priority=True),
|
|
38
|
+
Binding("N", "previous_match", show=False, priority=True),
|
|
39
|
+
Binding("escape", "cancel_search", "Cancel search", priority=True),
|
|
40
|
+
]
|
|
41
|
+
_searching = var(False, bindings=True)
|
|
42
|
+
|
|
43
|
+
DEFAULT_CSS = """
|
|
44
|
+
Pager { width: 1fr; height: 1fr; background: $surface; }
|
|
45
|
+
Pager #pager-title { height: 1; padding: 0 1; }
|
|
46
|
+
Pager #pager-search-row { display: none; height: 1; }
|
|
47
|
+
Pager #pager-search-direction { width: 1; }
|
|
48
|
+
Pager PagerSearchInput, Pager PagerSearchInput:focus {
|
|
49
|
+
width: 1fr; height: 1; border: none; padding: 0;
|
|
50
|
+
}
|
|
51
|
+
Pager PagerLog {
|
|
52
|
+
height: 1fr; min-height: 0; padding: 0 1; overflow: auto auto;
|
|
53
|
+
}
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, request: PageRequest) -> None:
|
|
57
|
+
super().__init__()
|
|
58
|
+
self.request = request
|
|
59
|
+
self.closed = anyio.Event()
|
|
60
|
+
self.loaded = False
|
|
61
|
+
self.error: Exception | None = None
|
|
62
|
+
self._pattern: re.Pattern[str] | None = None
|
|
63
|
+
self._direction = 1
|
|
64
|
+
self._match_line: int | None = None
|
|
65
|
+
|
|
66
|
+
def compose(self) -> ComposeResult:
|
|
67
|
+
yield Static(Text(self.request.title), id="pager-title")
|
|
68
|
+
yield PagerLog(wrap=True, markup=False, min_width=1, auto_scroll=False)
|
|
69
|
+
with Horizontal(id="pager-search-row"):
|
|
70
|
+
yield Static("/", id="pager-search-direction")
|
|
71
|
+
yield PagerSearchInput()
|
|
72
|
+
|
|
73
|
+
def on_mount(self) -> None:
|
|
74
|
+
self.focus()
|
|
75
|
+
|
|
76
|
+
async def load(self) -> None:
|
|
77
|
+
log = self.query_one(PagerLog)
|
|
78
|
+
try:
|
|
79
|
+
async for line in self.request.lines:
|
|
80
|
+
log.write(Text(line), scroll_end=False)
|
|
81
|
+
await anyio.lowlevel.checkpoint()
|
|
82
|
+
except Exception as error:
|
|
83
|
+
self.error = error
|
|
84
|
+
self.closed.set()
|
|
85
|
+
else:
|
|
86
|
+
self.loaded = True
|
|
87
|
+
|
|
88
|
+
def action_next_page(self) -> None:
|
|
89
|
+
self._match_line = None
|
|
90
|
+
self.query_one(PagerLog).scroll_page_down(animate=False)
|
|
91
|
+
|
|
92
|
+
def action_previous_page(self) -> None:
|
|
93
|
+
self._match_line = None
|
|
94
|
+
self.query_one(PagerLog).scroll_page_up(animate=False)
|
|
95
|
+
|
|
96
|
+
def action_next_line(self) -> None:
|
|
97
|
+
self._match_line = None
|
|
98
|
+
log = self.query_one(PagerLog)
|
|
99
|
+
log.scroll_to(y=log.scroll_y + 1, animate=False, immediate=True)
|
|
100
|
+
|
|
101
|
+
def action_previous_line(self) -> None:
|
|
102
|
+
self._match_line = None
|
|
103
|
+
log = self.query_one(PagerLog)
|
|
104
|
+
log.scroll_to(y=log.scroll_y - 1, animate=False, immediate=True)
|
|
105
|
+
|
|
106
|
+
def action_first_page(self) -> None:
|
|
107
|
+
self._match_line = None
|
|
108
|
+
self.query_one(PagerLog).scroll_home(animate=False)
|
|
109
|
+
|
|
110
|
+
def action_last_page(self) -> None:
|
|
111
|
+
self._match_line = None
|
|
112
|
+
self.query_one(PagerLog).scroll_end(animate=False)
|
|
113
|
+
|
|
114
|
+
def action_close_pager(self) -> None:
|
|
115
|
+
self.closed.set()
|
|
116
|
+
|
|
117
|
+
def action_force_close(self) -> None:
|
|
118
|
+
self.closed.set()
|
|
119
|
+
|
|
120
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
121
|
+
if action == "cancel_search":
|
|
122
|
+
return self._searching
|
|
123
|
+
if action in {
|
|
124
|
+
"search_forward",
|
|
125
|
+
"search_backward",
|
|
126
|
+
"next_match",
|
|
127
|
+
"previous_match",
|
|
128
|
+
}:
|
|
129
|
+
return self.request.searchable and not self._searching
|
|
130
|
+
if action != "force_close":
|
|
131
|
+
return not self._searching
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
def _start_search(self, direction: int) -> None:
|
|
135
|
+
self._direction = direction
|
|
136
|
+
self._searching = True
|
|
137
|
+
self.query_one("#pager-search-direction", Static).update(
|
|
138
|
+
"/" if direction > 0 else "?"
|
|
139
|
+
)
|
|
140
|
+
self.query_one("#pager-search-row").display = True
|
|
141
|
+
field = self.query_one(PagerSearchInput)
|
|
142
|
+
field.value = ""
|
|
143
|
+
field.focus()
|
|
144
|
+
|
|
145
|
+
def action_search_forward(self) -> None:
|
|
146
|
+
self._start_search(1)
|
|
147
|
+
|
|
148
|
+
def action_search_backward(self) -> None:
|
|
149
|
+
self._start_search(-1)
|
|
150
|
+
|
|
151
|
+
def action_cancel_search(self) -> None:
|
|
152
|
+
self._searching = False
|
|
153
|
+
self.query_one("#pager-search-row").display = False
|
|
154
|
+
self.focus()
|
|
155
|
+
|
|
156
|
+
@on(Input.Changed)
|
|
157
|
+
def search_changed(self, event: Input.Changed) -> None:
|
|
158
|
+
event.stop()
|
|
159
|
+
|
|
160
|
+
@on(Input.Submitted)
|
|
161
|
+
def search_submitted(self, event: Input.Submitted) -> None:
|
|
162
|
+
event.stop()
|
|
163
|
+
if event.value:
|
|
164
|
+
try:
|
|
165
|
+
self._pattern = re.compile(event.value)
|
|
166
|
+
except re.error as error:
|
|
167
|
+
self.app.notify(
|
|
168
|
+
str(error), title="Invalid search", severity="error", markup=False
|
|
169
|
+
)
|
|
170
|
+
return
|
|
171
|
+
self._match_line = None
|
|
172
|
+
self.action_cancel_search()
|
|
173
|
+
if self._pattern is not None:
|
|
174
|
+
self._find_match(self._direction, include_current=True)
|
|
175
|
+
|
|
176
|
+
def _find_match(self, direction: int, *, include_current: bool = False) -> None:
|
|
177
|
+
if self._pattern is None:
|
|
178
|
+
return
|
|
179
|
+
log = self.query_one(PagerLog)
|
|
180
|
+
count = len(log.lines)
|
|
181
|
+
if count:
|
|
182
|
+
start = (
|
|
183
|
+
self._match_line if self._match_line is not None else int(log.scroll_y)
|
|
184
|
+
)
|
|
185
|
+
first = 0 if include_current else 1
|
|
186
|
+
for step in range(first, count + first):
|
|
187
|
+
index = (start + direction * step) % count
|
|
188
|
+
if self._pattern.search(log.lines[index].text):
|
|
189
|
+
self._match_line = index
|
|
190
|
+
log.scroll_to(y=index, animate=False, immediate=True)
|
|
191
|
+
self.query_one("#pager-title", Static).update(
|
|
192
|
+
Text(
|
|
193
|
+
f"{self.request.title} · {self._pattern.pattern} · line {index + 1}"
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
return
|
|
197
|
+
self.app.notify("Pattern not found", title="Search", markup=False)
|
|
198
|
+
|
|
199
|
+
def action_next_match(self) -> None:
|
|
200
|
+
self._find_match(self._direction)
|
|
201
|
+
|
|
202
|
+
def action_previous_match(self) -> None:
|
|
203
|
+
self._find_match(-self._direction)
|
anyshell/presentation.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Human-readable output formatting, separate from pipeline values."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from .commands import render
|
|
6
|
+
|
|
7
|
+
LONG_FILE_HEADERS = ("Type", "Bytes", "Modified (UTC)", "Name")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_file_record(value) -> bool:
|
|
11
|
+
return (
|
|
12
|
+
isinstance(value, dict)
|
|
13
|
+
and isinstance(value.get("name"), str)
|
|
14
|
+
and value.get("type") in ("file", "directory", "symlink")
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_long_file_record(value) -> bool:
|
|
19
|
+
return is_file_record(value) and "size" in value and "modified" in value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def file_name(value) -> str:
|
|
23
|
+
return value.get("path", value["name"]) + (
|
|
24
|
+
"/" if value["type"] == "directory" else ""
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def long_file_cells(value) -> tuple[str, str, str, str]:
|
|
29
|
+
modified = value["modified"]
|
|
30
|
+
if isinstance(modified, (int, float)):
|
|
31
|
+
try:
|
|
32
|
+
modified = datetime.fromtimestamp(modified, timezone.utc).strftime(
|
|
33
|
+
"%Y-%m-%d %H:%M:%S"
|
|
34
|
+
)
|
|
35
|
+
except (OSError, OverflowError, ValueError):
|
|
36
|
+
modified = "?"
|
|
37
|
+
return (
|
|
38
|
+
{"directory": "dir", "symlink": "link", "file": "file"}[value["type"]],
|
|
39
|
+
str(value["size"]) if value["size"] is not None else "-",
|
|
40
|
+
str(modified) if modified is not None else "-",
|
|
41
|
+
file_name(value),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_long_line(cells) -> str:
|
|
46
|
+
kind, size, modified, name = cells
|
|
47
|
+
return f"{kind:4} {size:>10} {modified:19} {name}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_value(value) -> str:
|
|
51
|
+
if is_long_file_record(value):
|
|
52
|
+
return format_long_line(long_file_cells(value))
|
|
53
|
+
if is_file_record(value):
|
|
54
|
+
return file_name(value)
|
|
55
|
+
return render(value)
|
anyshell/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anyshell
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An async Python command shell with an optional Anytext frontend
|
|
5
|
+
Author: David Brochart
|
|
6
|
+
Author-email: David Brochart <david.brochart@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: anyio>=4.11,<5
|
|
10
|
+
Requires-Dist: cyclopts>=5.4,<6
|
|
11
|
+
Requires-Dist: anytext>=9.1,<10 ; extra == 'anytext'
|
|
12
|
+
Requires-Dist: trio>=0.31 ; extra == 'trio'
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Provides-Extra: anytext
|
|
15
|
+
Provides-Extra: trio
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Anyshell
|
|
19
|
+
|
|
20
|
+
An async shell whose commands are Python functions. The engine is independent of
|
|
21
|
+
its optional Anytext widget: both can run locally on the desktop or in Pyodide.
|
|
22
|
+
No shell command starts an OS process or needs a worker thread.
|
|
23
|
+
|
|
24
|
+
## Run it
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
uv sync --extra anytext --extra trio
|
|
28
|
+
uv run --extra anytext anyshell
|
|
29
|
+
uv run --extra anytext --extra trio anyshell --backend trio
|
|
30
|
+
uv run anyshell -c 'ls | where type file | select name | sort'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`/` maps to the selected workspace directory, which defaults to your current
|
|
34
|
+
working directory. Use `--root /path/to/workspace` to choose another directory.
|
|
35
|
+
The session never changes Python's process working directory. Paths use `/` on
|
|
36
|
+
all platforms. Local symlinks must resolve inside the workspace; this is a path
|
|
37
|
+
boundary, not a sandbox for arbitrary Python commands or untrusted code.
|
|
38
|
+
|
|
39
|
+
The prompt is inline by default: it follows the latest output in a shared scrollback
|
|
40
|
+
area. Typing or completing a command brings it back into view. For a fixed bottom
|
|
41
|
+
editor, run `uv run --extra anytext anyshell --prompt-position bottom` or construct
|
|
42
|
+
`Shell(session, prompt_position="bottom")`. Both `Shell` and `ShellApp` accept
|
|
43
|
+
`prompt_position="inline"` or `prompt_position="bottom"`.
|
|
44
|
+
|
|
45
|
+
The widget provides a scrollable transcript, Up/Down history, Tab completion for
|
|
46
|
+
command names, options, and unquoted paths, Ctrl+C cancellation, and Ctrl+Q to quit the demo.
|
|
47
|
+
Tab first extends the shared prefix when several completions match. If no more
|
|
48
|
+
characters can be filled in, Tab opens a menu beside the cursor. Use Up/Down or
|
|
49
|
+
Tab to select, Enter to insert, and Escape to dismiss; typing also dismisses it.
|
|
50
|
+
Long choices remain on one line and can be read using the horizontal scrollbar.
|
|
51
|
+
|
|
52
|
+
## Multiline commands
|
|
53
|
+
|
|
54
|
+
Shift+Enter inserts a newline without running the command. Enter submits a
|
|
55
|
+
complete command, or inserts a continuation line when a quote is still open or
|
|
56
|
+
the command ends with a pipe or backslash. Ctrl+O also inserts a newline on
|
|
57
|
+
terminals that cannot distinguish Shift+Enter. Ctrl+Enter forces submission,
|
|
58
|
+
including syntax errors. The editor grows up to eight rows, then scrolls.
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
echo 'hello
|
|
62
|
+
world'
|
|
63
|
+
cat notes.txt |
|
|
64
|
+
grep Python |
|
|
65
|
+
count
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Unquoted newlines separate commands, which execute in order. A backslash followed
|
|
69
|
+
by a newline joins lines without inserting a character. Newlines inside quotes
|
|
70
|
+
remain part of the argument. All commands are parsed and validated before any
|
|
71
|
+
runs; a runtime failure does not prevent later commands from running, and the
|
|
72
|
+
final status is that of the last command. Ctrl+C cancels the active sequence or
|
|
73
|
+
clears unfinished input. History keeps a multiline submission as one entry.
|
|
74
|
+
Up/Down moves within a multiline editor; Alt+Up/Down navigates command history.
|
|
75
|
+
The same syntax works through `Session.execute()` and headless `-c` execution.
|
|
76
|
+
|
|
77
|
+
## Commands and pipelines
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
help
|
|
81
|
+
ls
|
|
82
|
+
ls -la
|
|
83
|
+
help ls
|
|
84
|
+
ls | where type file | select name | sort
|
|
85
|
+
cat README.md | grep Python | count
|
|
86
|
+
echo 'a quoted | character'
|
|
87
|
+
mkdir notes
|
|
88
|
+
touch empty.txt
|
|
89
|
+
rmdir notes
|
|
90
|
+
mkdir notes
|
|
91
|
+
cd notes
|
|
92
|
+
write hello.txt 'Hello from Python'
|
|
93
|
+
cat hello.txt
|
|
94
|
+
edit hello.txt
|
|
95
|
+
cp hello.txt backup.txt
|
|
96
|
+
mv backup.txt renamed.txt
|
|
97
|
+
rm hello.txt
|
|
98
|
+
cd /
|
|
99
|
+
sleep 30
|
|
100
|
+
clear
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Pipelines exchange Python values through bounded AnyIO streams. `ls` emits
|
|
104
|
+
records, displayed as names in columns in the widget (directories have a trailing
|
|
105
|
+
`/` and blue styling). Headless output lists one name per line. Pipeline values
|
|
106
|
+
remain records; `select` extracts fields; `count` emits an integer. `cat` and `grep`
|
|
107
|
+
stream text lines. `sort` buffers its input. A failure in any stage fails the
|
|
108
|
+
pipeline. Filesystem mutations, `cd`, and `clear` run outside pipelines.
|
|
109
|
+
|
|
110
|
+
`ls -l` (`--long`) displays type, byte size, modification time in UTC, and name.
|
|
111
|
+
Its pipeline records include `size` and `modified` (Unix seconds); directory size
|
|
112
|
+
is `None`. `ls -a` (`--all`) includes hidden entries, and flags combine as `-la`.
|
|
113
|
+
`grep -i` ignores case and `sort -r` reverses the order.
|
|
114
|
+
`touch PATH...` creates missing files or updates modification times without
|
|
115
|
+
changing existing contents. Existing directories can also be touched.
|
|
116
|
+
`rm PATH...` removes files, continuing with remaining paths if one fails.
|
|
117
|
+
`rmdir DIRECTORY...` removes empty directories and rejects files and nonempty
|
|
118
|
+
directories; its completion suggests only directories.
|
|
119
|
+
|
|
120
|
+
This is an initial shell language: quoting, multiline commands, and pipes are supported. Bash scripts,
|
|
121
|
+
redirection, environment expansion, glob expansion, process execution, job
|
|
122
|
+
control, and persistent history are not implemented. Commands are trusted Python
|
|
123
|
+
code, not a sandbox. Browser filesystem changes are in-memory and reset on reload.
|
|
124
|
+
|
|
125
|
+
## Find files
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
find
|
|
129
|
+
find docs -name '*.txt'
|
|
130
|
+
find . -iname '*.py' -type f
|
|
131
|
+
find . -type d -maxdepth 2
|
|
132
|
+
find docs other-directory -type f | select path
|
|
133
|
+
find . -type f | count
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`find [PATH...]` walks starting paths recursively, including the starting entries
|
|
137
|
+
and hidden names. It defaults to `.`. `-name`/`--name` matches a basename using
|
|
138
|
+
shell-style wildcard patterns; `-iname`/`--iname` ignores letter case.
|
|
139
|
+
`-type`/`--type` accepts `f` (file), `d` (directory), or `l` (symbolic link).
|
|
140
|
+
`-maxdepth`/`--max-depth` limits traversal, with starting paths at depth zero.
|
|
141
|
+
Filters combine with AND. Quote wildcard patterns to pass them unchanged.
|
|
142
|
+
|
|
143
|
+
Output records contain `name` (basename), `path` (relative to the current working
|
|
144
|
+
directory, or absolute when the starting path is absolute), and `type` (`file`,
|
|
145
|
+
`directory`, or `symlink`). The UI displays paths with the usual directory suffix;
|
|
146
|
+
`select path` extracts plain paths. Symbolic links, including starting paths,
|
|
147
|
+
are listed without following them. Errors are reported while traversal continues
|
|
148
|
+
with other entries and starting paths; any error makes the command fail.
|
|
149
|
+
Custom filesystem implementations provide `entry_type()` without following the
|
|
150
|
+
final symbolic link, alongside the existing directory-listing methods.
|
|
151
|
+
|
|
152
|
+
## Move and copy files
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
mv old.txt new.txt
|
|
156
|
+
mv notes archive
|
|
157
|
+
cp file.txt backup.txt
|
|
158
|
+
cp file.txt existing-directory/
|
|
159
|
+
cp -r notes notes-backup
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`mv SOURCE DESTINATION` moves or renames a file or a whole directory.
|
|
163
|
+
`cp SOURCE DESTINATION` copies a file; `cp -r` (or `--recursive`) also copies
|
|
164
|
+
whole directory trees, including hidden entries. If the destination is an
|
|
165
|
+
existing directory, the source keeps its name inside it. Existing files are
|
|
166
|
+
replaced; existing directory trees are not merged or replaced. Contents and
|
|
167
|
+
modification times are preserved. Both commands accept one source and one
|
|
168
|
+
destination and run outside pipelines. Moving the current directory or one of
|
|
169
|
+
its ancestors updates the session's working path.
|
|
170
|
+
|
|
171
|
+
## Page through text
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
more README.md
|
|
175
|
+
more first.txt second.txt
|
|
176
|
+
cat notes.txt | grep Python | more
|
|
177
|
+
ls -l | more
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`more` fills the Shell widget with a read-only, scrollable view, then restores the
|
|
181
|
+
transcript and prompt when closed. Space/Page Down moves forward a page;
|
|
182
|
+
`b`/Page Up moves back. Enter/Down advances one line, Up moves back one line,
|
|
183
|
+
and Home/End jumps to the beginning/end. `q`, Ctrl+Q, or Escape closes the pager.
|
|
184
|
+
Mouse scrolling also works. Shortcuts appear in the footer.
|
|
185
|
+
|
|
186
|
+
The pager accepts files or pipeline input, formatting Python values for display.
|
|
187
|
+
It streams input into scrollback; closing it stops consuming the pipeline.
|
|
188
|
+
Headless execution reports that an interactive frontend is required. Other
|
|
189
|
+
frontends can handle the `page` event's `PageRequest`, consume its `lines`, mark
|
|
190
|
+
it `handled`, and return when their pager closes.
|
|
191
|
+
|
|
192
|
+
## Search with less
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
less README.md
|
|
196
|
+
less first.txt second.txt
|
|
197
|
+
cat notes.txt | grep Python | less
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`less` uses the same in-shell pager and navigation shortcuts as `more`, adding
|
|
201
|
+
Python regex search. Press `/` to search forward or `?` to search backward,
|
|
202
|
+
then enter a pattern and press Enter. `n` finds the next match in that direction;
|
|
203
|
+
Shift+N finds the previous one. Searches wrap around the text. An empty search
|
|
204
|
+
reuses the last pattern, and Escape cancels the search field. Invalid patterns
|
|
205
|
+
and missing matches appear as notifications. The title shows the matching line.
|
|
206
|
+
The search field disappears after submission, leaving no permanent bottom line.
|
|
207
|
+
|
|
208
|
+
## Edit files
|
|
209
|
+
|
|
210
|
+
`edit PATH` opens a plain text editor that fills the Shell widget, restoring the
|
|
211
|
+
transcript and prompt when closed. It loads an
|
|
212
|
+
existing UTF-8 file or starts empty for a new file. Ctrl+S saves without closing;
|
|
213
|
+
Ctrl+Q closes the editor and returns to the shell. Modified files show `*` beside the path. Closing with unsaved
|
|
214
|
+
changes updates the footer shortcuts: a second Ctrl+Q discards and closes (Ctrl+D
|
|
215
|
+
also works), Ctrl+S saves,
|
|
216
|
+
and Escape keeps editing. Save errors appear as notifications and leave the
|
|
217
|
+
editor and its text open. The title marks unsaved edits with `*`; shortcuts appear
|
|
218
|
+
in the app footer. The editor has no bottom status line or button rows. When embedding in
|
|
219
|
+
another Anytext app, give its app-wide Ctrl+Q quit binding `priority=False` so
|
|
220
|
+
the focused editor can handle that key first, as `ShellApp` does.
|
|
221
|
+
|
|
222
|
+
The editor uses the session filesystem on desktop and in the thread-free browser.
|
|
223
|
+
New files are created when saved. Browser files remain in memory until reload.
|
|
224
|
+
Headless `-c` execution reports that an interactive frontend is required; `edit`
|
|
225
|
+
cannot run in a pipeline. Other frontends can handle the `edit` event's
|
|
226
|
+
`EditRequest`, mark it `handled`, and return after their editor closes.
|
|
227
|
+
|
|
228
|
+
## Use the engine
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
import anyio
|
|
232
|
+
from anyshell import Event, MemoryFileSystem, Session
|
|
233
|
+
|
|
234
|
+
session = Session(MemoryFileSystem({"hello.txt": "hello\nworld\n"}))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def receive(event: Event):
|
|
238
|
+
if event.kind == "output":
|
|
239
|
+
print(event.value)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def main():
|
|
243
|
+
await session.execute("cat hello.txt | count", receive)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
anyio.run(main)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Commands use Cyclopts to parse typed Python parameters. `--help` (or `-h`)
|
|
250
|
+
and `help COMMAND` write help to the transcript. Invalid arguments produce shell
|
|
251
|
+
errors without exiting the app. All pipeline arguments are validated before any
|
|
252
|
+
stage starts. Use `--` before a filename beginning with a dash.
|
|
253
|
+
|
|
254
|
+
Register a `Command` with a synchronous or asynchronous function. Parameters
|
|
255
|
+
named `ctx` (or `context`), `stdin`, and `stdout` are injected by the engine and
|
|
256
|
+
must be keyword-only. Other parameters become command arguments and options.
|
|
257
|
+
Input and output are AnyIO object streams; a returned value is also emitted as
|
|
258
|
+
one pipeline value. Command functions should stay cooperative for cancellation.
|
|
259
|
+
`Session.cancel()` must be called from the session's event loop.
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
from typing import Annotated
|
|
263
|
+
|
|
264
|
+
from cyclopts import Parameter
|
|
265
|
+
from anyshell import Command
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def upper(
|
|
269
|
+
*,
|
|
270
|
+
prefix: Annotated[str, Parameter(name=["--prefix", "-p"])] = "",
|
|
271
|
+
stdin,
|
|
272
|
+
stdout,
|
|
273
|
+
):
|
|
274
|
+
async for value in stdin:
|
|
275
|
+
await stdout.send(prefix + str(value).upper())
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
session.register(Command("upper", "Uppercase input values", upper))
|
|
279
|
+
# Now: echo hello | upper -p '> '
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`ctx.fs` exposes the filesystem and `ctx.emit` emits frontend events. Parsing
|
|
283
|
+
and help never expose the injected parameters as command options.
|
|
284
|
+
|
|
285
|
+
For the frontend, import `Shell` from `anyshell.frontend` and yield
|
|
286
|
+
`Shell(session)` in any Anytext app. History and working directory live in the
|
|
287
|
+
session, so the engine and UI can evolve independently.
|
|
288
|
+
|
|
289
|
+
## Browser demo
|
|
290
|
+
|
|
291
|
+
```sh
|
|
292
|
+
uv run python tools/build_browser.py
|
|
293
|
+
uv run python -m http.server --directory .cache/browser-site 8000
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Open http://localhost:8000. The Prompt selector switches between inline and bottom
|
|
297
|
+
layouts by reloading the demo (`?prompt=inline` or `?prompt=bottom`). The demo downloads Pyodide and Anytext from public
|
|
298
|
+
package servers and installs this checkout's Anyshell wheel. It uses an in-memory
|
|
299
|
+
workspace and disables threads. Internet access is needed on first load.
|
|
300
|
+
|
|
301
|
+
## Development
|
|
302
|
+
|
|
303
|
+
```sh
|
|
304
|
+
uv run pytest
|
|
305
|
+
uv run ruff check .
|
|
306
|
+
uv run ruff format --check .
|
|
307
|
+
uv build
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
For the real browser regression test:
|
|
311
|
+
|
|
312
|
+
```sh
|
|
313
|
+
uv run python tools/build_browser.py
|
|
314
|
+
npm ci --prefix tests/browser
|
|
315
|
+
cd tests/browser
|
|
316
|
+
npx playwright install chromium
|
|
317
|
+
npm test
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Async engine and widget tests run on both asyncio and Trio. The engine depends
|
|
321
|
+
on AnyIO and Cyclopts; install `anyshell[anytext]` for the UI and `anyshell[trio]` for Trio.
|
|
322
|
+
|
|
323
|
+
The browser terminal scaffolding is adapted from Anytext's MIT-licensed browser demo.
|
|
324
|
+
|
|
325
|
+
## Releasing
|
|
326
|
+
|
|
327
|
+
Set `project.version` in `pyproject.toml`, update the lockfile with `uv lock`, and
|
|
328
|
+
commit the release changes. Push a matching version tag, without a `v` prefix:
|
|
329
|
+
|
|
330
|
+
```sh
|
|
331
|
+
git tag 0.1.0
|
|
332
|
+
git push origin 0.1.0
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The `release.yml` workflow checks that the tag matches the package version,
|
|
336
|
+
builds and validates the wheel and source distribution, then publishes them to
|
|
337
|
+
PyPI using the configured trusted publisher. No PyPI API token is needed.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
anyshell/__init__.py,sha256=CIZ0v1RIag9mrRlZ8TlGV7ST5AVZDF9XhJ8u2bErfJI,495
|
|
2
|
+
anyshell/__main__.py,sha256=-X2c7AX_IcWRPssvGYdUaLkIdxsBRTXPhu04BOJQm84,2373
|
|
3
|
+
anyshell/commands.py,sha256=8lGngwOusBaFGYSwD_-ttXdXjtst3ekK4aZhNIAOBSA,13069
|
|
4
|
+
anyshell/editor.py,sha256=tQzhH-ga4ARCROw_oo9pBjGXGemeDLbrwhIWgF5OHMk,4409
|
|
5
|
+
anyshell/engine.py,sha256=TFQcB20hRDoo27HaP1Rfi0Dda6Ixp24XyHmOdzZLtQQ,14302
|
|
6
|
+
anyshell/filesystem.py,sha256=URYcsa7AMeqAHutf9uU2TfGN3-EVpS0ojVtzq3rrMQk,13515
|
|
7
|
+
anyshell/frontend.py,sha256=T4Kwc0TUAgNCB70LXWHSpk4tqEUlG0m3Q88Uba4UWwQ,19490
|
|
8
|
+
anyshell/pager.py,sha256=THE4hkj4MxlxAFXcaiRhB5DZ7urj4QqvcA8x49F-3nI,7373
|
|
9
|
+
anyshell/presentation.py,sha256=eBnqJXbbeckeiYzUBq9Gx8F9_aAnOUmYjKUz1aeJZX8,1613
|
|
10
|
+
anyshell/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
anyshell-0.1.0.dist-info/licenses/LICENSE,sha256=l2NKIx1csL2iTBGQFGrbwo0WbAJ4BtZMpt2pzvx47TQ,1071
|
|
12
|
+
anyshell-0.1.0.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
|
|
13
|
+
anyshell-0.1.0.dist-info/entry_points.txt,sha256=kfGUbNkzxk_GQmo6pSFlo_y-_b3x_hQk055621wHReU,53
|
|
14
|
+
anyshell-0.1.0.dist-info/METADATA,sha256=l-6xiIzsMfIpjPjRoERWfpBNZThuaQTF8FEX9ka-WhI,13330
|
|
15
|
+
anyshell-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Brochart
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|