autosh 0.0.2__tar.gz → 0.0.4__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.
- {autosh-0.0.2 → autosh-0.0.4}/.gitignore +1 -0
- {autosh-0.0.2 → autosh-0.0.4}/PKG-INFO +11 -5
- {autosh-0.0.2 → autosh-0.0.4}/README.md +7 -3
- autosh-0.0.4/autosh/config-template.toml +13 -0
- {autosh-0.0.2 → autosh-0.0.4}/autosh/config.py +5 -0
- {autosh-0.0.2 → autosh-0.0.4}/autosh/main.py +12 -9
- {autosh-0.0.2 → autosh-0.0.4}/autosh/plugins/__init__.py +32 -16
- {autosh-0.0.2 → autosh-0.0.4}/autosh/plugins/calc.py +6 -3
- {autosh-0.0.2 → autosh-0.0.4}/autosh/plugins/cli.py +38 -31
- {autosh-0.0.2 → autosh-0.0.4}/autosh/plugins/clock.py +2 -3
- {autosh-0.0.2 → autosh-0.0.4}/autosh/plugins/code.py +22 -18
- {autosh-0.0.2 → autosh-0.0.4}/autosh/plugins/search.py +16 -8
- {autosh-0.0.2 → autosh-0.0.4}/autosh/plugins/web.py +2 -3
- {autosh-0.0.2 → autosh-0.0.4}/autosh/session.py +79 -102
- {autosh-0.0.2 → autosh-0.0.4}/pyproject.toml +9 -4
- autosh-0.0.2/autosh/md.py +0 -408
- {autosh-0.0.2 → autosh-0.0.4}/LICENSE +0 -0
- {autosh-0.0.2 → autosh-0.0.4}/autosh/__init__.py +0 -0
@@ -1,12 +1,14 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: autosh
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.4
|
4
4
|
Summary: Add your description here
|
5
5
|
License-File: LICENSE
|
6
6
|
Requires-Python: >=3.13
|
7
|
-
Requires-Dist: agentia>=0.0.
|
7
|
+
Requires-Dist: agentia>=0.0.7
|
8
8
|
Requires-Dist: asyncio>=3.4.3
|
9
9
|
Requires-Dist: markdownify>=1.1.0
|
10
|
+
Requires-Dist: neongrid
|
11
|
+
Requires-Dist: prompt-toolkit>=3.0.51
|
10
12
|
Requires-Dist: pydantic>=2.11.3
|
11
13
|
Requires-Dist: python-dotenv>=1.1.0
|
12
14
|
Requires-Dist: rich>=14.0.0
|
@@ -31,7 +33,9 @@ As an interactive shell: `ash` (alternatively, `autosh`)
|
|
31
33
|
|
32
34
|
Execute a single prompt: `ash "list current directory"`
|
33
35
|
|
34
|
-
Process piped data:
|
36
|
+
Process piped data:
|
37
|
+
* `cat README.md | ash -y "summarise"`
|
38
|
+
* `cat in.csv | ash -y -q "double the first numeric column" > out.csv`
|
35
39
|
|
36
40
|
## Scripting
|
37
41
|
|
@@ -72,6 +76,8 @@ Write "Hello, world" to _test.log
|
|
72
76
|
|
73
77
|
# TODO
|
74
78
|
|
75
|
-
- [ ] Image generation
|
76
|
-
- [ ] Image input
|
79
|
+
- [ ] Image input, generation, and editing
|
77
80
|
- [ ] RAG for non-text files
|
81
|
+
- [ ] Plugin system
|
82
|
+
- [ ] MCP support
|
83
|
+
- [ ] A better input widget with history and auto completion
|
@@ -14,7 +14,9 @@ As an interactive shell: `ash` (alternatively, `autosh`)
|
|
14
14
|
|
15
15
|
Execute a single prompt: `ash "list current directory"`
|
16
16
|
|
17
|
-
Process piped data:
|
17
|
+
Process piped data:
|
18
|
+
* `cat README.md | ash -y "summarise"`
|
19
|
+
* `cat in.csv | ash -y -q "double the first numeric column" > out.csv`
|
18
20
|
|
19
21
|
## Scripting
|
20
22
|
|
@@ -55,6 +57,8 @@ Write "Hello, world" to _test.log
|
|
55
57
|
|
56
58
|
# TODO
|
57
59
|
|
58
|
-
- [ ] Image generation
|
59
|
-
- [ ] Image input
|
60
|
+
- [ ] Image input, generation, and editing
|
60
61
|
- [ ] RAG for non-text files
|
62
|
+
- [ ] Plugin system
|
63
|
+
- [ ] MCP support
|
64
|
+
- [ ] A better input widget with history and auto completion
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# autosh configuration file
|
2
|
+
[autosh]
|
3
|
+
api_key = "sk-or-v1-..."
|
4
|
+
# model = "openai/gpt-4.1"
|
5
|
+
# think_model = "openai/o4-mini-high"
|
6
|
+
|
7
|
+
[plugins]
|
8
|
+
calc = {}
|
9
|
+
cli = {}
|
10
|
+
clock = {}
|
11
|
+
code = {}
|
12
|
+
# search = { tavily_api_key = "tvly-dev-..." }
|
13
|
+
# web = { tavily_api_key = "tvly-dev-..." }
|
@@ -43,6 +43,11 @@ class Config(BaseModel):
|
|
43
43
|
|
44
44
|
@staticmethod
|
45
45
|
def load() -> "Config":
|
46
|
+
if not USER_CONFIG_PATH.is_file():
|
47
|
+
# Copy config.template.toml to USER_CONFIG_PATH
|
48
|
+
USER_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
49
|
+
template = Path(__file__).parent / "config.template.toml"
|
50
|
+
USER_CONFIG_PATH.write_text(template.read_text())
|
46
51
|
if USER_CONFIG_PATH.is_file():
|
47
52
|
try:
|
48
53
|
doc = tomllib.loads(USER_CONFIG_PATH.read_text())
|
@@ -1,14 +1,16 @@
|
|
1
1
|
import os
|
2
|
+
|
3
|
+
os.environ["AGENTIA_DISABLE_PLUGINS"] = "1"
|
4
|
+
|
2
5
|
from pathlib import Path
|
3
6
|
import rich
|
4
7
|
import typer
|
5
8
|
import asyncio
|
6
|
-
import dotenv
|
7
9
|
from rich.columns import Columns
|
8
10
|
from rich.panel import Panel
|
9
11
|
import argparse
|
10
12
|
|
11
|
-
from autosh.config import CLI_OPTIONS, CONFIG
|
13
|
+
from autosh.config import CLI_OPTIONS, CONFIG, USER_CONFIG_PATH
|
12
14
|
from .session import Session
|
13
15
|
import sys
|
14
16
|
|
@@ -26,6 +28,8 @@ app = typer.Typer(
|
|
26
28
|
async def start_session(prompt: str | None, args: list[str]):
|
27
29
|
CLI_OPTIONS.args = args
|
28
30
|
session = Session()
|
31
|
+
os.environ["OPENROUTER_HAS_REASONING"] = "false"
|
32
|
+
os.environ["OPENROUTER_INCLUDE_REASONING"] = "false"
|
29
33
|
await session.init()
|
30
34
|
piped_stdin = not sys.stdin.isatty()
|
31
35
|
if piped_stdin and not CLI_OPTIONS.yes:
|
@@ -149,14 +153,13 @@ def main():
|
|
149
153
|
# dotenv.load_dotenv()
|
150
154
|
prompt, args = parse_args()
|
151
155
|
|
156
|
+
if key := os.getenv("OPENROUTER_API_KEY"):
|
157
|
+
CONFIG.api_key = key
|
152
158
|
if CONFIG.api_key is None:
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
"[bold red]Error:[/bold red] [red]No API key found. Please set the OPENROUTER_API_KEY environment variable or add it to your config file.[/red]"
|
158
|
-
)
|
159
|
-
sys.exit(1)
|
159
|
+
rich.print(
|
160
|
+
f"[bold red]Error:[/bold red] [red]OpenRouter API key not found.\nPlease set the OPENROUTER_API_KEY environment variable or add it to your config file: {USER_CONFIG_PATH}.[/red]"
|
161
|
+
)
|
162
|
+
sys.exit(1)
|
160
163
|
try:
|
161
164
|
asyncio.run(start_session(prompt, args))
|
162
165
|
except (KeyboardInterrupt, EOFError):
|
@@ -1,3 +1,4 @@
|
|
1
|
+
from typing import Any, Callable
|
1
2
|
import rich
|
2
3
|
from rich.prompt import Confirm
|
3
4
|
from rich.panel import Panel
|
@@ -5,40 +6,55 @@ from rich.console import RenderableType
|
|
5
6
|
from autosh.config import CLI_OPTIONS, CONFIG
|
6
7
|
|
7
8
|
|
8
|
-
def
|
9
|
+
def __print_simple_banner(tag: str, text: str | None = None, dim: str | None = None):
|
9
10
|
if CLI_OPTIONS.quiet:
|
10
11
|
return
|
11
|
-
s = f"[bold magenta]{tag}[/bold magenta]"
|
12
|
+
s = f"\n[bold on magenta] {tag} [/bold on magenta]"
|
12
13
|
if text:
|
13
14
|
s += f" [italic magenta]{text}[/italic magenta]"
|
14
15
|
if dim:
|
15
16
|
s += f" [italic dim]{dim}[/italic dim]"
|
16
|
-
s += "\n"
|
17
17
|
rich.print(s)
|
18
18
|
|
19
19
|
|
20
|
-
def
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
20
|
+
def simple_banner(
|
21
|
+
tag: str | Callable[[Any], str],
|
22
|
+
text: Callable[[Any], str] | None = None,
|
23
|
+
dim: Callable[[Any], str] | None = None,
|
24
|
+
):
|
25
|
+
return lambda x: __print_simple_banner(
|
26
|
+
tag if isinstance(tag, str) else tag(x),
|
27
|
+
text(x) if text else None,
|
28
|
+
dim(x) if dim else None,
|
25
29
|
)
|
26
|
-
if not CLI_OPTIONS.quiet:
|
27
|
-
rich.print()
|
28
|
-
return result
|
29
30
|
|
30
31
|
|
31
|
-
def
|
32
|
-
|
33
|
-
|
34
|
-
|
32
|
+
def __print_code_preview_banner(
|
33
|
+
title: str, content: RenderableType, short: str | None = None
|
34
|
+
):
|
35
|
+
if CLI_OPTIONS.quiet:
|
36
|
+
if short and not CLI_OPTIONS.yes:
|
37
|
+
rich.print(f"\n[magenta]{short}[/magenta]\n")
|
35
38
|
return
|
36
39
|
panel = Panel.fit(content, title=f"[magenta]{title}[/magenta]", title_align="left")
|
40
|
+
rich.print()
|
37
41
|
rich.print(panel)
|
38
42
|
rich.print()
|
39
43
|
|
40
44
|
|
41
|
-
def
|
45
|
+
def code_preview_banner(
|
46
|
+
title: str | Callable[[Any], str],
|
47
|
+
short: str | Callable[[Any], str],
|
48
|
+
content: Callable[[Any], RenderableType],
|
49
|
+
):
|
50
|
+
return lambda x: __print_code_preview_banner(
|
51
|
+
title=title if isinstance(title, str) else title(x),
|
52
|
+
content=content(x),
|
53
|
+
short=short if isinstance(short, str) else short(x),
|
54
|
+
)
|
55
|
+
|
56
|
+
|
57
|
+
def code_result_panel(
|
42
58
|
title: str,
|
43
59
|
out: str | None = None,
|
44
60
|
err: str | None = None,
|
@@ -1,12 +1,16 @@
|
|
1
1
|
from agentia.plugins import tool, Plugin
|
2
2
|
from typing import Annotated
|
3
|
-
from . import
|
3
|
+
from . import simple_banner
|
4
4
|
|
5
5
|
|
6
6
|
class CalculatorPlugin(Plugin):
|
7
7
|
NAME = "calc"
|
8
8
|
|
9
|
-
@tool
|
9
|
+
@tool(
|
10
|
+
metadata={
|
11
|
+
"banner": simple_banner("CALC", dim=lambda a: a.get("expression", ""))
|
12
|
+
}
|
13
|
+
)
|
10
14
|
def evaluate(
|
11
15
|
self,
|
12
16
|
expression: Annotated[
|
@@ -16,7 +20,6 @@ class CalculatorPlugin(Plugin):
|
|
16
20
|
"""
|
17
21
|
Execute a math expression and return the result. The expression must be an valid python expression that can be execuated by `eval()`.
|
18
22
|
"""
|
19
|
-
banner("CALC", expression)
|
20
23
|
|
21
24
|
result = eval(expression)
|
22
25
|
return result
|
@@ -1,14 +1,14 @@
|
|
1
1
|
import os
|
2
2
|
import sys
|
3
3
|
from typing import Annotated
|
4
|
-
from agentia
|
4
|
+
from agentia import Plugin, tool, UserConsentEvent
|
5
5
|
import rich
|
6
6
|
import subprocess
|
7
7
|
from enum import StrEnum
|
8
8
|
|
9
9
|
from autosh.config import CLI_OPTIONS
|
10
10
|
|
11
|
-
from . import
|
11
|
+
from . import code_result_panel, code_preview_banner, simple_banner
|
12
12
|
|
13
13
|
|
14
14
|
class Color(StrEnum):
|
@@ -59,22 +59,20 @@ class CLIPlugin(Plugin):
|
|
59
59
|
rich.print(text, file=sys.stderr if stderr else sys.stdout, end=end)
|
60
60
|
return "DONE. You can continue and no need to repeat the text"
|
61
61
|
|
62
|
-
@tool
|
62
|
+
@tool(metadata={"banner": simple_banner("CWD", dim=lambda a: a.get("path", ""))})
|
63
63
|
def chdir(self, path: Annotated[str, "The path to the new working directory"]):
|
64
64
|
"""
|
65
65
|
Changes the current working directory of the terminal to another directory.
|
66
66
|
"""
|
67
|
-
banner("CWD", path)
|
68
67
|
if not os.path.exists(path):
|
69
68
|
raise FileNotFoundError(f"Path `{path}` does not exist.")
|
70
69
|
os.chdir(path)
|
71
70
|
|
72
|
-
@tool
|
71
|
+
@tool(metadata={"banner": simple_banner("GET ARGV")})
|
73
72
|
def get_argv(self):
|
74
73
|
"""
|
75
74
|
Get the command line arguments.
|
76
75
|
"""
|
77
|
-
banner("GET ARGV")
|
78
76
|
if not CLI_OPTIONS.script:
|
79
77
|
return CLI_OPTIONS.args
|
80
78
|
return {
|
@@ -82,28 +80,34 @@ class CLIPlugin(Plugin):
|
|
82
80
|
"args": CLI_OPTIONS.args,
|
83
81
|
}
|
84
82
|
|
85
|
-
@tool
|
83
|
+
@tool(metadata={"banner": simple_banner("GET ENV", dim=lambda a: a.get("key", ""))})
|
86
84
|
def get_env(self, key: Annotated[str, "The environment variable to get"]):
|
87
85
|
"""
|
88
86
|
Get an environment variable.
|
89
87
|
"""
|
90
|
-
banner("GET ENV", key)
|
91
88
|
if key not in os.environ:
|
92
89
|
raise KeyError(f"Environment variable `{key}` does not exist.")
|
93
90
|
return os.environ[key]
|
94
91
|
|
95
|
-
@tool
|
92
|
+
@tool(metadata={"banner": simple_banner("GET ALL ENVS")})
|
96
93
|
def get_all_envs(self):
|
97
94
|
"""
|
98
95
|
Get all environment variables.
|
99
96
|
"""
|
100
|
-
banner("GET ALL ENVS")
|
101
97
|
envs = {}
|
102
98
|
for key, value in os.environ.items():
|
103
99
|
envs[key] = value
|
104
100
|
return {"envs": envs}
|
105
101
|
|
106
|
-
@tool
|
102
|
+
@tool(
|
103
|
+
metadata={
|
104
|
+
"banner": simple_banner(
|
105
|
+
tag=lambda a: "SET ENV" if a.get("value") else "DEL ENV",
|
106
|
+
text=lambda a: a.get("key", ""),
|
107
|
+
dim=lambda a: a.get("value", "") or "",
|
108
|
+
),
|
109
|
+
}
|
110
|
+
)
|
107
111
|
def update_env(
|
108
112
|
self,
|
109
113
|
key: Annotated[str, "The environment variable to set"],
|
@@ -116,15 +120,13 @@ class CLIPlugin(Plugin):
|
|
116
120
|
Set or delete an environment variable.
|
117
121
|
"""
|
118
122
|
if value is None:
|
119
|
-
banner("DEL ENV", key)
|
120
123
|
if key in os.environ:
|
121
124
|
del os.environ[key]
|
122
125
|
else:
|
123
|
-
banner("SET ENV", key, value)
|
124
126
|
os.environ[key] = value
|
125
127
|
return f"DONE"
|
126
128
|
|
127
|
-
@tool
|
129
|
+
@tool(metadata={"banner": simple_banner("READ", dim=lambda a: a.get("path", ""))})
|
128
130
|
def read(
|
129
131
|
self,
|
130
132
|
path: Annotated[str, "The path to the file to read"],
|
@@ -132,7 +134,6 @@ class CLIPlugin(Plugin):
|
|
132
134
|
"""
|
133
135
|
Read a file and print its content.
|
134
136
|
"""
|
135
|
-
banner("READ", path)
|
136
137
|
if not os.path.exists(path):
|
137
138
|
raise FileNotFoundError(f"File `{path}` does not exist.")
|
138
139
|
if not os.path.isfile(path):
|
@@ -141,7 +142,15 @@ class CLIPlugin(Plugin):
|
|
141
142
|
content = f.read()
|
142
143
|
return content
|
143
144
|
|
144
|
-
@tool
|
145
|
+
@tool(
|
146
|
+
metadata={
|
147
|
+
"banner": simple_banner(
|
148
|
+
tag=lambda a: "WRITE" if not a.get("append") else "APPEND",
|
149
|
+
text=lambda a: a.get("path", ""),
|
150
|
+
dim=lambda a: f"({len(a.get('content', ''))} bytes)",
|
151
|
+
),
|
152
|
+
}
|
153
|
+
)
|
145
154
|
def write(
|
146
155
|
self,
|
147
156
|
path: Annotated[str, "The path to the file to write"],
|
@@ -154,8 +163,6 @@ class CLIPlugin(Plugin):
|
|
154
163
|
"""
|
155
164
|
Write or append text content to a file.
|
156
165
|
"""
|
157
|
-
banner("WRITE" if not append else "APPEND", path, f"({len(content)} bytes)")
|
158
|
-
|
159
166
|
if not create and not os.path.exists(path):
|
160
167
|
raise FileNotFoundError(f"File `{path}` does not exist.")
|
161
168
|
if not create and not os.path.isfile(path):
|
@@ -164,7 +171,7 @@ class CLIPlugin(Plugin):
|
|
164
171
|
raise FileExistsError(
|
165
172
|
f"No, you cannot overwrite the script file `{path}`. You're likely writing to it by mistake."
|
166
173
|
)
|
167
|
-
if not
|
174
|
+
if not (yield UserConsentEvent("Write file?")):
|
168
175
|
return {"error": "The user declined the write operation."}
|
169
176
|
flag = "a" if append else "w"
|
170
177
|
if create:
|
@@ -200,7 +207,15 @@ class CLIPlugin(Plugin):
|
|
200
207
|
raise RuntimeError("No piped input from stdin")
|
201
208
|
return sys.stdin.read()
|
202
209
|
|
203
|
-
@tool
|
210
|
+
@tool(
|
211
|
+
metadata={
|
212
|
+
"banner": code_preview_banner(
|
213
|
+
title="Run Command",
|
214
|
+
short=lambda a: f"[magenta][bold]➜[/bold] [italic]{a.get("command", "")}[/italic][/magenta]",
|
215
|
+
content=lambda a: f"[magenta][bold]➜[/bold] [italic]{a.get("command", "")}[/italic][/magenta]\n\n[dim]{a.get("explanation", "")}[/dim]",
|
216
|
+
)
|
217
|
+
}
|
218
|
+
)
|
204
219
|
def exec(
|
205
220
|
self,
|
206
221
|
command: Annotated[
|
@@ -220,15 +235,8 @@ class CLIPlugin(Plugin):
|
|
220
235
|
cmd = ["bash", "-c", command]
|
221
236
|
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
222
237
|
|
223
|
-
# Print the command and explanation
|
224
|
-
cmd_preview_panel(
|
225
|
-
title="Run Command",
|
226
|
-
content=f"[magenta][bold]➜[/bold] [italic]{command}[/italic][/magenta]\n\n[dim]{explanation}[/dim]",
|
227
|
-
short=f"[magenta][bold]➜[/bold] [italic]{command}[/italic][/magenta]",
|
228
|
-
)
|
229
|
-
|
230
238
|
# Ask for confirmation
|
231
|
-
if not
|
239
|
+
if not (yield UserConsentEvent("Execute this command?")):
|
232
240
|
return {"error": "The user declined to execute the command."}
|
233
241
|
|
234
242
|
# Execute the command
|
@@ -245,7 +253,7 @@ class CLIPlugin(Plugin):
|
|
245
253
|
title = f"[red][bold]✘[/bold] Command Failed [{proc_result.returncode}][/red]"
|
246
254
|
else:
|
247
255
|
title = "[green][bold]✔[/bold] Command Finished[/green]"
|
248
|
-
|
256
|
+
code_result_panel(title, out, err)
|
249
257
|
|
250
258
|
result = {
|
251
259
|
"stdout": proc_result.stdout.decode("utf-8"),
|
@@ -255,10 +263,9 @@ class CLIPlugin(Plugin):
|
|
255
263
|
}
|
256
264
|
return result
|
257
265
|
|
258
|
-
@tool
|
266
|
+
@tool(metadata={"banner": simple_banner("EXIT")})
|
259
267
|
def exit(self, exitcode: Annotated[int, "The exit code of this shell session"] = 0):
|
260
268
|
"""
|
261
269
|
Exit the current shell session with an optional exit code.
|
262
270
|
"""
|
263
|
-
banner("EXIT", str(exitcode))
|
264
271
|
sys.exit(exitcode)
|
@@ -2,14 +2,13 @@ import datetime
|
|
2
2
|
from agentia.plugins import tool, Plugin
|
3
3
|
import tzlocal
|
4
4
|
|
5
|
-
from autosh.plugins import
|
5
|
+
from autosh.plugins import simple_banner
|
6
6
|
|
7
7
|
|
8
8
|
class ClockPlugin(Plugin):
|
9
|
-
@tool
|
9
|
+
@tool(metadata={"banner": simple_banner("GET TIME")})
|
10
10
|
def get_current_time(self):
|
11
11
|
"""Get the current UTC time in ISO format"""
|
12
|
-
banner("GET TIME")
|
13
12
|
utc = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
14
13
|
local = datetime.datetime.now().isoformat()
|
15
14
|
timezone = tzlocal.get_localzone_name()
|
@@ -1,15 +1,32 @@
|
|
1
|
-
from agentia
|
1
|
+
from agentia import tool, Plugin, UserConsentEvent
|
2
2
|
from typing import Annotated
|
3
3
|
import traceback
|
4
4
|
from rich.syntax import Syntax
|
5
5
|
from rich.console import group
|
6
6
|
from contextlib import redirect_stdout, redirect_stderr
|
7
7
|
import io
|
8
|
-
from . import
|
8
|
+
from . import code_preview_banner, code_result_panel
|
9
|
+
|
10
|
+
|
11
|
+
@group()
|
12
|
+
def code_with_explanation(code: str, explanation: str):
|
13
|
+
yield Syntax(code.strip(), "python")
|
14
|
+
yield "\n[dim]───[/dim]\n"
|
15
|
+
yield f"[dim]{explanation}[/dim]"
|
9
16
|
|
10
17
|
|
11
18
|
class CodePlugin(Plugin):
|
12
|
-
@tool
|
19
|
+
@tool(
|
20
|
+
metadata={
|
21
|
+
"banner": code_preview_banner(
|
22
|
+
title="Run Python",
|
23
|
+
short="[bold]RUN[/bold] [italic]Python Code[/italic]",
|
24
|
+
content=lambda a: code_with_explanation(
|
25
|
+
a.get("python_code", ""), a.get("explanation", "")
|
26
|
+
),
|
27
|
+
)
|
28
|
+
}
|
29
|
+
)
|
13
30
|
def execute(
|
14
31
|
self,
|
15
32
|
python_code: Annotated[str, "The python code to run."],
|
@@ -22,20 +39,7 @@ class CodePlugin(Plugin):
|
|
22
39
|
The python code must be a valid python source file that accepts no inputs.
|
23
40
|
Print results to stdout or stderr.
|
24
41
|
"""
|
25
|
-
|
26
|
-
@group()
|
27
|
-
def code_with_explanation():
|
28
|
-
yield Syntax(python_code.strip(), "python")
|
29
|
-
yield "\n[dim]───[/dim]\n"
|
30
|
-
yield f"[dim]{explanation}[/dim]"
|
31
|
-
|
32
|
-
cmd_preview_panel(
|
33
|
-
title="Run Python",
|
34
|
-
content=code_with_explanation(),
|
35
|
-
short=f"[bold]RUN[/bold] [italic]Python Code[/italic]",
|
36
|
-
)
|
37
|
-
|
38
|
-
if not confirm("Execute this code?"):
|
42
|
+
if not (yield UserConsentEvent("Execute this code?")):
|
39
43
|
return {"error": "The user declined to execute the command."}
|
40
44
|
|
41
45
|
out = io.StringIO()
|
@@ -64,5 +68,5 @@ class CodePlugin(Plugin):
|
|
64
68
|
"traceback": repr(traceback.format_exc()),
|
65
69
|
}
|
66
70
|
|
67
|
-
|
71
|
+
code_result_panel(title, o, e)
|
68
72
|
return result
|
@@ -3,7 +3,7 @@ from typing import Annotated, override
|
|
3
3
|
import os
|
4
4
|
from tavily import TavilyClient
|
5
5
|
|
6
|
-
from autosh.plugins import
|
6
|
+
from autosh.plugins import simple_banner
|
7
7
|
|
8
8
|
|
9
9
|
class SearchPlugin(Plugin):
|
@@ -17,7 +17,11 @@ class SearchPlugin(Plugin):
|
|
17
17
|
raise ValueError("Please set the TAVILY_API_KEY environment variable.")
|
18
18
|
self.__tavily = TavilyClient(api_key=key)
|
19
19
|
|
20
|
-
@tool
|
20
|
+
@tool(
|
21
|
+
metadata={
|
22
|
+
"banner": simple_banner("WEB SEARCH", dim=lambda a: a.get("query", "")),
|
23
|
+
}
|
24
|
+
)
|
21
25
|
async def web_search(
|
22
26
|
self,
|
23
27
|
query: Annotated[
|
@@ -29,8 +33,6 @@ class SearchPlugin(Plugin):
|
|
29
33
|
Returning the top related search results in json format.
|
30
34
|
When necessary, you need to combine this tool with the get_webpage_content tools (if available), to browse the web in depth by jumping through links.
|
31
35
|
"""
|
32
|
-
banner("WEB SEARCH", dim=query)
|
33
|
-
|
34
36
|
tavily_results = self.__tavily.search(
|
35
37
|
query=query,
|
36
38
|
search_depth="advanced",
|
@@ -41,7 +43,11 @@ class SearchPlugin(Plugin):
|
|
41
43
|
)
|
42
44
|
return tavily_results
|
43
45
|
|
44
|
-
@tool
|
46
|
+
@tool(
|
47
|
+
metadata={
|
48
|
+
"banner": simple_banner("NEWS SEARCH", dim=lambda a: a.get("query", "")),
|
49
|
+
}
|
50
|
+
)
|
45
51
|
async def news_search(
|
46
52
|
self,
|
47
53
|
query: Annotated[
|
@@ -52,7 +58,6 @@ class SearchPlugin(Plugin):
|
|
52
58
|
Perform news search on the given query.
|
53
59
|
Returning the top related results in json format.
|
54
60
|
"""
|
55
|
-
banner("NEWS SEARCH", dim=query)
|
56
61
|
|
57
62
|
tavily_results = self.__tavily.search(
|
58
63
|
query=query,
|
@@ -65,7 +70,11 @@ class SearchPlugin(Plugin):
|
|
65
70
|
)
|
66
71
|
return tavily_results
|
67
72
|
|
68
|
-
@tool
|
73
|
+
@tool(
|
74
|
+
metadata={
|
75
|
+
"banner": simple_banner("FINANCE SEARCH", dim=lambda a: a.get("query", ""))
|
76
|
+
}
|
77
|
+
)
|
69
78
|
async def finance_search(
|
70
79
|
self,
|
71
80
|
query: Annotated[
|
@@ -76,7 +85,6 @@ class SearchPlugin(Plugin):
|
|
76
85
|
Search for finance-related news and information on the given query.
|
77
86
|
Returning the top related results in json format.
|
78
87
|
"""
|
79
|
-
banner("FINANCE SEARCH", dim=query)
|
80
88
|
|
81
89
|
tavily_results = self.__tavily.search(
|
82
90
|
query=query,
|
@@ -7,7 +7,7 @@ from markdownify import markdownify
|
|
7
7
|
import uuid
|
8
8
|
from tavily import TavilyClient
|
9
9
|
|
10
|
-
from autosh.plugins import
|
10
|
+
from autosh.plugins import simple_banner
|
11
11
|
|
12
12
|
|
13
13
|
class WebPlugin(Plugin):
|
@@ -47,7 +47,7 @@ class WebPlugin(Plugin):
|
|
47
47
|
md = markdownify(res.text)
|
48
48
|
return {"content": md}
|
49
49
|
|
50
|
-
@tool
|
50
|
+
@tool(metadata={"banner": simple_banner("BROWSE", dim=lambda a: a.get("url", ""))})
|
51
51
|
def get_webpage_content(
|
52
52
|
self,
|
53
53
|
url: Annotated[str, "The URL of the web page to get the content of"],
|
@@ -57,7 +57,6 @@ class WebPlugin(Plugin):
|
|
57
57
|
You can always use this tool to directly access web content or access external sites.
|
58
58
|
Use it at any time when you think you may need to access the internet.
|
59
59
|
"""
|
60
|
-
banner("BROWSE", dim=url)
|
61
60
|
|
62
61
|
result = self.__tavily.extract(
|
63
62
|
urls=url,
|
@@ -1,17 +1,24 @@
|
|
1
1
|
import asyncio
|
2
2
|
from pathlib import Path
|
3
3
|
import sys
|
4
|
-
from agentia import
|
5
|
-
|
6
|
-
|
4
|
+
from agentia import (
|
5
|
+
Agent,
|
6
|
+
UserMessage,
|
7
|
+
Event,
|
8
|
+
ToolCallEvent,
|
9
|
+
MessageStream,
|
10
|
+
ChatCompletion,
|
11
|
+
UserConsentEvent,
|
12
|
+
)
|
7
13
|
from agentia.plugins import PluginInitError
|
14
|
+
from neongrid.loading import Loading
|
8
15
|
|
9
16
|
from autosh.config import CLI_OPTIONS, CONFIG
|
10
|
-
|
17
|
+
import neongrid as ng
|
11
18
|
from .plugins import create_plugins
|
12
19
|
import rich
|
13
20
|
import platform
|
14
|
-
from rich.prompt import
|
21
|
+
from rich.prompt import Confirm
|
15
22
|
|
16
23
|
|
17
24
|
INSTRUCTIONS = f"""
|
@@ -79,16 +86,16 @@ class Session:
|
|
79
86
|
* -h, --help Show this message and exit.
|
80
87
|
""",
|
81
88
|
)
|
82
|
-
agent.history.add(self.
|
89
|
+
agent.history.add(self.__get_argv_message())
|
83
90
|
completion = agent.chat_completion(prompt, stream=True)
|
84
91
|
async for stream in completion:
|
85
92
|
await self.__render_streamed_markdown(stream)
|
86
93
|
sys.exit(0)
|
87
94
|
|
88
|
-
def
|
95
|
+
def __get_argv_message(self):
|
89
96
|
args = str(CLI_OPTIONS.args)
|
90
97
|
if not CLI_OPTIONS.script:
|
91
|
-
cmd =
|
98
|
+
cmd = Path(sys.argv[0]).name
|
92
99
|
else:
|
93
100
|
cmd = CLI_OPTIONS.script.name
|
94
101
|
return UserMessage(
|
@@ -112,21 +119,30 @@ class Session:
|
|
112
119
|
# Execute the prompt
|
113
120
|
loading = self.__create_loading_indicator()
|
114
121
|
CLI_OPTIONS.prompt = prompt
|
115
|
-
self.agent.history.add(self.
|
122
|
+
self.agent.history.add(self.__get_argv_message())
|
116
123
|
if CLI_OPTIONS.stdin_has_data():
|
117
124
|
self.agent.history.add(
|
118
125
|
UserMessage(
|
119
|
-
content="IMPORTANT:
|
126
|
+
content="IMPORTANT: You are acting as an intermediate tool of a workflow. Input data is fed to you through piped stdin. Please use tools to read when necessary.",
|
120
127
|
role="user",
|
121
128
|
)
|
122
129
|
)
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
+
if not sys.stdout.isatty():
|
131
|
+
self.agent.history.add(
|
132
|
+
UserMessage(
|
133
|
+
content="IMPORTANT: You are acting as an intermediate tool of a workflow. Your output should only contain the user expected output, nothing else. Don't ask user questions or print anything else since the user cannot see it.",
|
134
|
+
role="user",
|
135
|
+
)
|
136
|
+
)
|
137
|
+
else:
|
138
|
+
self.agent.history.add(
|
139
|
+
UserMessage(
|
140
|
+
content="IMPORTANT: This is a one-off run, so don't ask user questions since the user cannot reply.",
|
141
|
+
role="user",
|
142
|
+
)
|
143
|
+
)
|
144
|
+
completion = self.agent.chat_completion(prompt, stream=True, events=True)
|
145
|
+
await self.__process_completion(completion, loading)
|
130
146
|
|
131
147
|
async def exec_from_stdin(self):
|
132
148
|
if sys.stdin.isatty():
|
@@ -143,104 +159,65 @@ class Session:
|
|
143
159
|
prompt = f.read()
|
144
160
|
await self.exec_prompt(prompt)
|
145
161
|
|
162
|
+
async def __process_event(self, e: Event):
|
163
|
+
if isinstance(e, UserConsentEvent):
|
164
|
+
e.response = await self.__confirm(e.message)
|
165
|
+
if isinstance(e, ToolCallEvent) and e.result is None:
|
166
|
+
if banner := (e.metadata or {}).get("banner"):
|
167
|
+
banner(e.arguments)
|
168
|
+
|
169
|
+
async def __confirm(self, message: str) -> bool:
|
170
|
+
if CLI_OPTIONS.yes:
|
171
|
+
return True
|
172
|
+
result = Confirm.ask(
|
173
|
+
f"[magenta]{message}[/magenta]", default=True, case_sensitive=False
|
174
|
+
)
|
175
|
+
if not CLI_OPTIONS.quiet:
|
176
|
+
rich.print()
|
177
|
+
return result
|
178
|
+
|
179
|
+
async def __process_completion(
|
180
|
+
self, completion: ChatCompletion[Event | MessageStream], loading: Loading
|
181
|
+
):
|
182
|
+
async for stream in completion:
|
183
|
+
await loading.finish()
|
184
|
+
|
185
|
+
if isinstance(stream, Event):
|
186
|
+
await self.__process_event(stream)
|
187
|
+
else:
|
188
|
+
print()
|
189
|
+
await self.__render_streamed_markdown(stream)
|
190
|
+
print()
|
191
|
+
|
192
|
+
loading = self.__create_loading_indicator()
|
193
|
+
|
194
|
+
await loading.finish()
|
195
|
+
|
146
196
|
async def run_repl(self):
|
147
|
-
console = rich.console.Console()
|
148
197
|
while True:
|
149
198
|
try:
|
150
|
-
prompt =
|
199
|
+
prompt = (
|
200
|
+
await ng.input("> ", sync=False, persist="/tmp/autosh-history")
|
201
|
+
).strip()
|
151
202
|
if prompt in ["exit", "quit"]:
|
152
203
|
break
|
153
204
|
if len(prompt) == 0:
|
154
205
|
continue
|
155
|
-
loading = self.__create_loading_indicator(
|
156
|
-
completion = self.agent.chat_completion(
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
if await self.__render_streamed_markdown(stream, loading=loading):
|
161
|
-
print()
|
162
|
-
loading = None
|
206
|
+
loading = self.__create_loading_indicator()
|
207
|
+
completion = self.agent.chat_completion(
|
208
|
+
prompt, stream=True, events=True
|
209
|
+
)
|
210
|
+
await self.__process_completion(completion, loading)
|
163
211
|
except KeyboardInterrupt:
|
164
212
|
break
|
165
213
|
|
166
|
-
def __create_loading_indicator(self
|
167
|
-
return (
|
168
|
-
asyncio.create_task(self.__loading(newline))
|
169
|
-
if sys.stdout.isatty()
|
170
|
-
else None
|
171
|
-
)
|
214
|
+
def __create_loading_indicator(self):
|
215
|
+
return ng.loading.kana()
|
172
216
|
|
173
|
-
async def
|
174
|
-
chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
175
|
-
char_width = 1
|
176
|
-
msg = "Loading..."
|
177
|
-
count = 0
|
178
|
-
print("\x1b[2m", end="", flush=True)
|
179
|
-
while True:
|
180
|
-
try:
|
181
|
-
print(chars[count], end="", flush=True)
|
182
|
-
print(" " + msg, end="", flush=True)
|
183
|
-
count += 1
|
184
|
-
await asyncio.sleep(0.1)
|
185
|
-
length = char_width + len(msg) + 1
|
186
|
-
print("\b" * length, end="", flush=True)
|
187
|
-
print(" " * length, end="", flush=True)
|
188
|
-
print("\b" * length, end="", flush=True)
|
189
|
-
if count == len(chars):
|
190
|
-
count = 0
|
191
|
-
except asyncio.CancelledError:
|
192
|
-
length = char_width + len(msg) + 1
|
193
|
-
print("\b" * length, end="", flush=True)
|
194
|
-
print(" " * length, end="", flush=True)
|
195
|
-
print("\b" * length, end="", flush=True)
|
196
|
-
print("\x1b[0m", end="", flush=True)
|
197
|
-
if newline:
|
198
|
-
print()
|
199
|
-
break
|
200
|
-
|
201
|
-
async def __render_streamed_markdown(
|
202
|
-
self, stream: MessageStream, loading: asyncio.Task[None] | None = None
|
203
|
-
):
|
217
|
+
async def __render_streamed_markdown(self, stream: MessageStream):
|
204
218
|
if sys.stdout.isatty():
|
205
|
-
|
206
|
-
chunks = aiter(stream)
|
207
|
-
buf = ""
|
208
|
-
while len(buf) < 8:
|
209
|
-
try:
|
210
|
-
buf += await anext(chunks)
|
211
|
-
except StopAsyncIteration:
|
212
|
-
if len(buf) == 0:
|
213
|
-
if loading:
|
214
|
-
loading.cancel()
|
215
|
-
await loading
|
216
|
-
return False
|
217
|
-
break
|
218
|
-
if loading:
|
219
|
-
loading.cancel()
|
220
|
-
await loading
|
221
|
-
|
222
|
-
content = {"v": ""}
|
223
|
-
|
224
|
-
async def gen():
|
225
|
-
content["v"] = buf
|
226
|
-
if buf:
|
227
|
-
yield buf
|
228
|
-
while True:
|
229
|
-
try:
|
230
|
-
s = await anext(chunks)
|
231
|
-
content["v"] += s
|
232
|
-
for c in s:
|
233
|
-
yield c
|
234
|
-
except StopAsyncIteration:
|
235
|
-
break
|
236
|
-
|
237
|
-
await stream_md(gen())
|
219
|
+
await ng.stream.markdown(aiter(stream))
|
238
220
|
return True
|
239
221
|
else:
|
240
|
-
has_content = False
|
241
222
|
async for chunk in stream:
|
242
|
-
if chunk == "":
|
243
|
-
continue
|
244
|
-
has_content = True
|
245
223
|
print(chunk, end="", flush=True)
|
246
|
-
return has_content
|
@@ -1,19 +1,21 @@
|
|
1
1
|
[project]
|
2
2
|
name = "autosh"
|
3
|
-
version = "0.0.
|
3
|
+
version = "0.0.4"
|
4
4
|
description = "Add your description here"
|
5
5
|
readme = "README.md"
|
6
6
|
requires-python = ">=3.13"
|
7
7
|
dependencies = [
|
8
|
-
"agentia>=0.0.5",
|
9
8
|
"asyncio>=3.4.3",
|
10
9
|
"markdownify>=1.1.0",
|
10
|
+
"prompt-toolkit>=3.0.51",
|
11
11
|
"pydantic>=2.11.3",
|
12
12
|
"python-dotenv>=1.1.0",
|
13
13
|
"rich>=14.0.0",
|
14
14
|
"tavily-python>=0.5.4",
|
15
15
|
"typer>=0.12.5",
|
16
16
|
"tzlocal>=5.3.1",
|
17
|
+
"neongrid",
|
18
|
+
"agentia>=0.0.7",
|
17
19
|
]
|
18
20
|
|
19
21
|
[project.scripts]
|
@@ -26,8 +28,11 @@ include = ["autosh"]
|
|
26
28
|
[tool.hatch.build.targets.wheel]
|
27
29
|
include = ["autosh"]
|
28
30
|
|
29
|
-
|
30
|
-
|
31
|
+
[tool.uv.sources]
|
32
|
+
neongrid = { workspace = true }
|
33
|
+
|
34
|
+
[tool.uv.workspace]
|
35
|
+
members = ["packages/neongrid"]
|
31
36
|
|
32
37
|
[build-system]
|
33
38
|
requires = ["hatchling"]
|
autosh-0.0.2/autosh/md.py
DELETED
@@ -1,408 +0,0 @@
|
|
1
|
-
import os
|
2
|
-
from typing import AsyncGenerator, Literal
|
3
|
-
|
4
|
-
|
5
|
-
HIGHLIGHT_COLOR_START = "35"
|
6
|
-
HIGHLIGHT_COLOR_END = "0"
|
7
|
-
|
8
|
-
|
9
|
-
class MarkdowmPrinter:
|
10
|
-
def __init__(self, stream: AsyncGenerator[str, None]):
|
11
|
-
async def char_stream(stream: AsyncGenerator[str, None]):
|
12
|
-
async for chunk in stream:
|
13
|
-
for char in chunk:
|
14
|
-
yield char
|
15
|
-
|
16
|
-
self.stream = char_stream(stream)
|
17
|
-
self.__buf: str = ""
|
18
|
-
self.__eof = False
|
19
|
-
|
20
|
-
def peek(self):
|
21
|
-
c = self.__buf[0] if len(self.__buf) > 0 else None
|
22
|
-
return c
|
23
|
-
|
24
|
-
async def __ensure_length(self, n: int):
|
25
|
-
while (not self.__eof) and len(self.__buf) < n:
|
26
|
-
try:
|
27
|
-
self.__buf += await self.stream.__anext__()
|
28
|
-
except StopAsyncIteration:
|
29
|
-
self.__eof = True
|
30
|
-
|
31
|
-
async def __check_unordered_list_label(self) -> bool:
|
32
|
-
if self.__eof:
|
33
|
-
return False
|
34
|
-
await self.__ensure_length(2)
|
35
|
-
buf = self.__buf
|
36
|
-
if len(buf) < 2:
|
37
|
-
return False
|
38
|
-
if buf[0] in ["-", "+", "*"] and buf[1] == " ":
|
39
|
-
return True
|
40
|
-
return False
|
41
|
-
|
42
|
-
async def __check_ordered_list_label(self) -> bool:
|
43
|
-
if self.__eof:
|
44
|
-
return False
|
45
|
-
await self.__ensure_length(5)
|
46
|
-
buf = self.__buf
|
47
|
-
# \d+\.
|
48
|
-
if len(buf) == 0:
|
49
|
-
return False
|
50
|
-
if not buf[0].isnumeric():
|
51
|
-
return False
|
52
|
-
has_dot = False
|
53
|
-
for i in range(1, 5):
|
54
|
-
if i >= len(buf):
|
55
|
-
return False
|
56
|
-
c = buf[i]
|
57
|
-
if c == ".":
|
58
|
-
if has_dot:
|
59
|
-
return False
|
60
|
-
has_dot = True
|
61
|
-
continue
|
62
|
-
if c == " ":
|
63
|
-
if has_dot:
|
64
|
-
return True
|
65
|
-
return False
|
66
|
-
if c.isnumeric():
|
67
|
-
continue
|
68
|
-
return False
|
69
|
-
|
70
|
-
async def check(self, s: str):
|
71
|
-
if len(s) == 0:
|
72
|
-
return True
|
73
|
-
await self.__ensure_length(len(s))
|
74
|
-
if len(self.__buf) < len(s):
|
75
|
-
return False
|
76
|
-
return self.__buf[0 : len(s)] == s
|
77
|
-
|
78
|
-
async def check_non_paragraph_block_start(self):
|
79
|
-
await self.__ensure_length(3)
|
80
|
-
buf = self.__buf[:3] if len(self.__buf) >= 3 else self.__buf
|
81
|
-
if buf.startswith("```"):
|
82
|
-
return True
|
83
|
-
if buf.startswith("---"):
|
84
|
-
return True
|
85
|
-
if buf.startswith("> "):
|
86
|
-
return True
|
87
|
-
if await self.__check_ordered_list_label():
|
88
|
-
return True
|
89
|
-
if await self.__check_unordered_list_label():
|
90
|
-
return True
|
91
|
-
return False
|
92
|
-
|
93
|
-
async def next(self):
|
94
|
-
c = self.__buf[0] if len(self.__buf) > 0 else None
|
95
|
-
self.__buf = self.__buf[1:] if len(self.__buf) > 0 else ""
|
96
|
-
if c is None:
|
97
|
-
return None
|
98
|
-
if not self.__eof:
|
99
|
-
try:
|
100
|
-
self.__buf += await self.stream.__anext__()
|
101
|
-
except StopAsyncIteration:
|
102
|
-
self.__eof = True
|
103
|
-
return c
|
104
|
-
|
105
|
-
def print(self, s: str):
|
106
|
-
print(s, end="", flush=True)
|
107
|
-
|
108
|
-
async def parse_single_line_text(
|
109
|
-
self,
|
110
|
-
outer_is_italic: bool = False,
|
111
|
-
outer_is_bold: bool = False,
|
112
|
-
outer_is_dim: bool = False,
|
113
|
-
):
|
114
|
-
styles: list[Literal["code", "bold", "italic", "strike"]] = []
|
115
|
-
|
116
|
-
def find(s: Literal["code", "bold", "italic", "strike"]):
|
117
|
-
for i in range(len(styles) - 1, -1, -1):
|
118
|
-
if styles[i] == s:
|
119
|
-
return i
|
120
|
-
return None
|
121
|
-
|
122
|
-
def find_italic_first():
|
123
|
-
for i in range(len(styles) - 1, -1, -1):
|
124
|
-
if styles[i] == "bold":
|
125
|
-
return False
|
126
|
-
if styles[i] == "italic":
|
127
|
-
return True
|
128
|
-
return False
|
129
|
-
|
130
|
-
# Remove leading spaces
|
131
|
-
while self.peek() in [" ", "\t"]:
|
132
|
-
await self.next()
|
133
|
-
|
134
|
-
while True:
|
135
|
-
not_code = find("code") is None
|
136
|
-
c = self.peek()
|
137
|
-
if c == "\n" or c is None:
|
138
|
-
await self.next()
|
139
|
-
self.print("\x1b[0m\n") # Reset all and newline
|
140
|
-
return
|
141
|
-
match c:
|
142
|
-
case "`":
|
143
|
-
await self.next()
|
144
|
-
if (i := find("code")) is not None:
|
145
|
-
self.print(c)
|
146
|
-
if not outer_is_dim:
|
147
|
-
self.print("\x1b[22m")
|
148
|
-
if find("bold") is not None or outer_is_bold:
|
149
|
-
self.print("\x1b[1m")
|
150
|
-
styles = styles[:i]
|
151
|
-
else:
|
152
|
-
self.print("\x1b[2m")
|
153
|
-
styles.append("code")
|
154
|
-
self.print(c)
|
155
|
-
# Bold
|
156
|
-
case "*" if (
|
157
|
-
not_code and await self.check("**") and not find_italic_first()
|
158
|
-
):
|
159
|
-
await self.next()
|
160
|
-
await self.next()
|
161
|
-
# print(">", styles, find("bold"))
|
162
|
-
if (i := find("bold")) is not None:
|
163
|
-
self.print(c)
|
164
|
-
self.print(c)
|
165
|
-
if not outer_is_bold:
|
166
|
-
self.print("\x1b[22m")
|
167
|
-
styles = styles[:i]
|
168
|
-
else:
|
169
|
-
self.print("\x1b[1m")
|
170
|
-
styles.append("bold")
|
171
|
-
self.print(c)
|
172
|
-
self.print(c)
|
173
|
-
case "_" if (
|
174
|
-
not_code and await self.check("__") and not find_italic_first()
|
175
|
-
):
|
176
|
-
await self.next()
|
177
|
-
await self.next()
|
178
|
-
if (i := find("bold")) is not None:
|
179
|
-
self.print(c)
|
180
|
-
self.print(c)
|
181
|
-
if not outer_is_bold:
|
182
|
-
self.print("\x1b[22m")
|
183
|
-
styles = styles[:i]
|
184
|
-
else:
|
185
|
-
self.print("\x1b[1m")
|
186
|
-
styles.append("bold")
|
187
|
-
self.print(c)
|
188
|
-
self.print(c)
|
189
|
-
# Italic
|
190
|
-
case "*" | "_" if (
|
191
|
-
not_code
|
192
|
-
and not await self.check("* ")
|
193
|
-
and not await self.check("_ ")
|
194
|
-
):
|
195
|
-
await self.next()
|
196
|
-
if (i := find("italic")) is not None:
|
197
|
-
self.print(c)
|
198
|
-
if not outer_is_italic:
|
199
|
-
self.print("\x1b[23m")
|
200
|
-
styles = styles[:i]
|
201
|
-
# print(styles, await self.check("**"))
|
202
|
-
else:
|
203
|
-
self.print("\x1b[3m")
|
204
|
-
styles.append("italic")
|
205
|
-
self.print(c)
|
206
|
-
# Strike through
|
207
|
-
case "~" if not_code and await self.check("~~"):
|
208
|
-
await self.next()
|
209
|
-
await self.next()
|
210
|
-
if (i := find("strike")) is not None:
|
211
|
-
self.print("~~")
|
212
|
-
self.print("\x1b[29m")
|
213
|
-
styles = styles[:i]
|
214
|
-
else:
|
215
|
-
self.print("\x1b[9m")
|
216
|
-
styles.append("strike")
|
217
|
-
self.print("~~")
|
218
|
-
case _:
|
219
|
-
self.print(c)
|
220
|
-
await self.next()
|
221
|
-
|
222
|
-
async def parse_heading(self):
|
223
|
-
hashes = 0
|
224
|
-
while True:
|
225
|
-
c = await self.next()
|
226
|
-
if c == "#":
|
227
|
-
hashes += 1
|
228
|
-
else:
|
229
|
-
break
|
230
|
-
# Start control
|
231
|
-
match hashes:
|
232
|
-
case 1:
|
233
|
-
self.print("\x1b[45;1;2m") # Magenta background, bold, dim
|
234
|
-
self.print("#" * hashes)
|
235
|
-
self.print(" \x1b[22;1m") # Reset dim
|
236
|
-
await self.parse_single_line_text(outer_is_bold=True)
|
237
|
-
case 2:
|
238
|
-
self.print("\x1b[35;1;2;4m") # Magenta foreground, bold, dim, underline
|
239
|
-
self.print("#" * hashes)
|
240
|
-
self.print(" \x1b[22m\x1b[1m") # Reset dim
|
241
|
-
await self.parse_single_line_text(outer_is_bold=True)
|
242
|
-
case 3:
|
243
|
-
self.print("\x1b[35;1;2m") # Magenta foreground, bold, dim
|
244
|
-
self.print("#" * hashes)
|
245
|
-
self.print(" \x1b[22m\x1b[1m") # Reset dim
|
246
|
-
await self.parse_single_line_text(outer_is_bold=True)
|
247
|
-
case 4:
|
248
|
-
self.print("\x1b[35;2;3m") # Magenta foreground, dim, italic
|
249
|
-
self.print("#" * hashes)
|
250
|
-
self.print(" \x1b[22m") # Reset dim
|
251
|
-
await self.parse_single_line_text(outer_is_italic=True)
|
252
|
-
case _:
|
253
|
-
self.print("\x1b[2m") # dim
|
254
|
-
self.print("#" * hashes)
|
255
|
-
self.print(" \x1b[22m") # Reset dim
|
256
|
-
await self.parse_single_line_text()
|
257
|
-
# Stream title
|
258
|
-
|
259
|
-
async def parse_paragraph(self):
|
260
|
-
while True:
|
261
|
-
await self.parse_single_line_text()
|
262
|
-
if self.peek() != "\n" and not await self.check_non_paragraph_block_start():
|
263
|
-
await self.next()
|
264
|
-
break
|
265
|
-
else:
|
266
|
-
break
|
267
|
-
|
268
|
-
async def parse_multiline_code(self):
|
269
|
-
# dim
|
270
|
-
self.print("\x1b[2m")
|
271
|
-
self.print("```")
|
272
|
-
await self.next()
|
273
|
-
await self.next()
|
274
|
-
await self.next()
|
275
|
-
while not await self.check("\n```"):
|
276
|
-
c = await self.next()
|
277
|
-
if c is None:
|
278
|
-
self.print("\n")
|
279
|
-
return
|
280
|
-
self.print(c)
|
281
|
-
self.print("\n```\n")
|
282
|
-
await self.next()
|
283
|
-
await self.next()
|
284
|
-
await self.next()
|
285
|
-
await self.next()
|
286
|
-
|
287
|
-
async def parse_list(self, ordered: bool):
|
288
|
-
indents = [0]
|
289
|
-
counter = [1]
|
290
|
-
# first item
|
291
|
-
if ordered:
|
292
|
-
self.print("1. ")
|
293
|
-
await self.next()
|
294
|
-
else:
|
295
|
-
self.print("• ")
|
296
|
-
await self.next()
|
297
|
-
await self.parse_single_line_text()
|
298
|
-
while True:
|
299
|
-
indent = 0
|
300
|
-
while self.peek() in [" ", "\t", "\n"]:
|
301
|
-
if self.peek() in [" ", "\t"]:
|
302
|
-
indent += 1
|
303
|
-
if self.peek() == "\n":
|
304
|
-
indent = 0
|
305
|
-
await self.next()
|
306
|
-
if self.peek() is None:
|
307
|
-
return
|
308
|
-
if ordered and not await self.__check_ordered_list_label():
|
309
|
-
return
|
310
|
-
if not ordered and not await self.__check_unordered_list_label():
|
311
|
-
return
|
312
|
-
if not ordered:
|
313
|
-
await self.next()
|
314
|
-
else:
|
315
|
-
while self.peek() is not None and self.peek() != ".":
|
316
|
-
await self.next()
|
317
|
-
await self.next()
|
318
|
-
|
319
|
-
depth = None
|
320
|
-
for i in range(len(indents) - 1):
|
321
|
-
if indents[i] <= indent and indents[i + 1] > indent:
|
322
|
-
depth = i
|
323
|
-
break
|
324
|
-
if depth is None and indents[-1] + 2 <= indent:
|
325
|
-
# indent one more level
|
326
|
-
indents.append(indent)
|
327
|
-
depth = len(indents) - 1
|
328
|
-
counter.append(1)
|
329
|
-
elif depth is None:
|
330
|
-
# same as last level
|
331
|
-
depth = len(indents) - 1
|
332
|
-
counter[depth] += 1
|
333
|
-
else:
|
334
|
-
# dedent
|
335
|
-
indents = indents[: depth + 1]
|
336
|
-
counter = counter[: depth + 1]
|
337
|
-
counter[depth] += 1
|
338
|
-
if not ordered:
|
339
|
-
self.print(" " * depth + "• ")
|
340
|
-
else:
|
341
|
-
self.print(" " * depth + str(counter[depth]) + ". ")
|
342
|
-
await self.parse_single_line_text()
|
343
|
-
|
344
|
-
async def parse_blockquote(self):
|
345
|
-
while True:
|
346
|
-
while self.peek() in [" ", "\t"]:
|
347
|
-
await self.next()
|
348
|
-
if self.peek() != ">":
|
349
|
-
break
|
350
|
-
await self.next()
|
351
|
-
self.print("\x1b[1;2m|\x1b[22;2m ")
|
352
|
-
await self.parse_single_line_text(outer_is_dim=True)
|
353
|
-
|
354
|
-
async def parse_doc(self):
|
355
|
-
self.__buf = await self.stream.__anext__()
|
356
|
-
start = True
|
357
|
-
while True:
|
358
|
-
# Remove leading spaces and empty lines
|
359
|
-
indent = 0
|
360
|
-
while self.peek() in [" ", "\t", "\n"]:
|
361
|
-
if self.peek() in [" ", "\t"]:
|
362
|
-
indent += 1
|
363
|
-
if self.peek() == "\n":
|
364
|
-
indent = 0
|
365
|
-
await self.next()
|
366
|
-
if self.peek() is None:
|
367
|
-
break
|
368
|
-
if not start:
|
369
|
-
self.print("\n")
|
370
|
-
start = False
|
371
|
-
match c := self.peek():
|
372
|
-
case None:
|
373
|
-
break
|
374
|
-
# Heading
|
375
|
-
case "#":
|
376
|
-
await self.parse_heading()
|
377
|
-
# Code
|
378
|
-
case "`" if await self.check("```"):
|
379
|
-
await self.parse_multiline_code()
|
380
|
-
# Separator
|
381
|
-
case _ if await self.check("---"):
|
382
|
-
await self.next()
|
383
|
-
await self.next()
|
384
|
-
await self.next()
|
385
|
-
width = min(os.get_terminal_size().columns, 80)
|
386
|
-
self.print("\x1b[2m" + "─" * width + "\x1b[22m\n")
|
387
|
-
# Unordered list
|
388
|
-
case _ if await self.__check_unordered_list_label():
|
389
|
-
await self.parse_list(False)
|
390
|
-
# Ordered list
|
391
|
-
case _ if await self.__check_ordered_list_label():
|
392
|
-
await self.parse_list(True)
|
393
|
-
# Blockquote
|
394
|
-
case ">":
|
395
|
-
await self.parse_blockquote()
|
396
|
-
# Normal paragraph
|
397
|
-
case _:
|
398
|
-
await self.parse_paragraph()
|
399
|
-
self.print("\x1b[0m\x1b[0m\x1b[0m") # Reset all
|
400
|
-
self.print("\x1b[0m") # Reset all
|
401
|
-
|
402
|
-
def __await__(self):
|
403
|
-
return self.parse_doc().__await__()
|
404
|
-
|
405
|
-
|
406
|
-
async def stream_md(stream: AsyncGenerator[str, None]):
|
407
|
-
mp = MarkdowmPrinter(stream)
|
408
|
-
await mp.parse_doc()
|
File without changes
|
File without changes
|