autosh 0.0.0__py3-none-any.whl → 0.0.1__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.
autosh/config.py ADDED
@@ -0,0 +1,78 @@
1
+ import sys
2
+ from pydantic import BaseModel, Field
3
+ from pathlib import Path
4
+ import tomllib
5
+
6
+ USER_CONFIG_PATH = Path.home() / ".config" / "autosh" / "config.toml"
7
+
8
+
9
+ class EmptyConfig(BaseModel): ...
10
+
11
+
12
+ class SearchConfig(BaseModel):
13
+ tavily_api_key: str = Field(..., description="Tavily API key.")
14
+
15
+
16
+ class WebConfig(BaseModel):
17
+ tavily_api_key: str = Field(..., description="Tavily API key.")
18
+
19
+
20
+ class Plugins(BaseModel):
21
+ calc: EmptyConfig | None = None
22
+ cli: EmptyConfig | None = None
23
+ clock: EmptyConfig | None = None
24
+ code: EmptyConfig | None = None
25
+ search: SearchConfig | None = None
26
+ web: WebConfig | None = None
27
+
28
+
29
+ class Config(BaseModel):
30
+ model: str = Field(default="openai/gpt-4.1", description="The LLM model to use")
31
+ think_model: str = Field(
32
+ default="openai/o4-mini-high",
33
+ description="The LLM model to use for reasoning before executing commands",
34
+ )
35
+ api_key: str | None = Field(default=None, description="OpenRouter API key.")
36
+
37
+ plugins: Plugins = Field(
38
+ default_factory=Plugins,
39
+ description="Plugin configuration. Set to null to disable the plugin.",
40
+ )
41
+
42
+ @staticmethod
43
+ def load() -> "Config":
44
+ if USER_CONFIG_PATH.is_file():
45
+ doc = tomllib.loads(USER_CONFIG_PATH.read_text())
46
+ main = doc.get("autosh", {})
47
+ plugins = Plugins(**doc.get("plugins", {}))
48
+ config = Config.model_validate({**main, "plugins": plugins})
49
+ else:
50
+ config = Config()
51
+ return config
52
+
53
+
54
+ CONFIG = Config.load()
55
+
56
+
57
+ class CLIOptions(BaseModel):
58
+ yes: bool = False
59
+ quiet: bool = False
60
+ think: bool = False
61
+
62
+ prompt: str | None = None
63
+ """The prompt to execute"""
64
+
65
+ script: Path | None = None
66
+ """The scripe providing the prompt"""
67
+
68
+ stdin_is_script: bool = False
69
+ """STDIN is a script, not a piped input."""
70
+
71
+ args: list[str] = Field(default_factory=list, description="Command line arguments")
72
+
73
+ def stdin_has_data(self) -> bool:
74
+ """Check if stdin has data."""
75
+ return not sys.stdin.isatty() and not CLI_OPTIONS.stdin_is_script
76
+
77
+
78
+ CLI_OPTIONS = CLIOptions()
autosh/main.py ADDED
@@ -0,0 +1,164 @@
1
+ import os
2
+ from pathlib import Path
3
+ import rich
4
+ import typer
5
+ import asyncio
6
+ import dotenv
7
+ from rich.columns import Columns
8
+ from rich.panel import Panel
9
+ import argparse
10
+
11
+ from autosh.config import CLI_OPTIONS, CONFIG
12
+ from .session import Session
13
+ import sys
14
+
15
+
16
+ app = typer.Typer(
17
+ no_args_is_help=False,
18
+ add_completion=False,
19
+ context_settings=dict(help_option_names=["-h", "--help"]),
20
+ pretty_exceptions_short=True,
21
+ pretty_exceptions_show_locals=False,
22
+ help="Autosh is a command line tool that helps you automate your tasks using LLMs.",
23
+ )
24
+
25
+
26
+ async def start_session(prompt: str | None, args: list[str]):
27
+ CLI_OPTIONS.args = args
28
+ session = Session()
29
+ await session.init()
30
+ piped_stdin = not sys.stdin.isatty()
31
+ if piped_stdin and not CLI_OPTIONS.yes:
32
+ rich.print(
33
+ "[bold red]Error:[/bold red] [red]--yes is required when using piped stdin.[/red]"
34
+ )
35
+ sys.exit(1)
36
+ if prompt:
37
+ # No piped stdin, just run the prompt
38
+ if Path(prompt).is_file():
39
+ # Prompt is a file, read it and execute it
40
+ await session.exec_script(Path(prompt))
41
+ else:
42
+ # Prompt is a string, execute it directly
43
+ await session.exec_prompt(prompt)
44
+ elif not prompt and not sys.stdin.isatty():
45
+ # Piped stdin without prompt, treat piped stdin as a prompt.
46
+ await session.exec_from_stdin()
47
+ else:
48
+ await session.run_repl()
49
+
50
+
51
+ def print_help():
52
+ cmd = Path(sys.argv[0]).name
53
+ rich.print(
54
+ f"\n [bold yellow]Usage:[/bold yellow] [bold]{cmd} [OPTIONS] [--] [PROMPT_OR_FILE] [ARGS]...[/bold]\n"
55
+ )
56
+
57
+ args = [
58
+ ["prompt_or_file", "[PROMPT_OR_FILE]", "The prompt or file to execute."],
59
+ ["args", "[ARGS]...", "The arguments to pass to the script."],
60
+ ]
61
+ options = [
62
+ ["--yes", "-y", "Auto confirm all prompts."],
63
+ ["--quiet", "-q", "Suppress all output."],
64
+ [
65
+ "--model",
66
+ "-m",
67
+ f"The LLM model to use. [dim]Default: {CONFIG.model} ({CONFIG.think_model} for reasoning).[/dim]",
68
+ ],
69
+ ["--think", "", "Use the reasoning models to think more before operating."],
70
+ ["--help", "-h", "Show this message and exit."],
71
+ ]
72
+
73
+ rich.print(
74
+ Panel.fit(
75
+ Columns(
76
+ [
77
+ "\n".join([a[0] for a in args]),
78
+ "\n".join([f"[bold yellow]\\{a[1]}[/bold yellow]" for a in args]),
79
+ "\n".join([" " + a[2] for a in args]),
80
+ ],
81
+ padding=(0, 3),
82
+ ),
83
+ title="[dim]Arguments[/dim]",
84
+ title_align="left",
85
+ padding=(0, 3),
86
+ )
87
+ )
88
+
89
+ rich.print(
90
+ Panel.fit(
91
+ Columns(
92
+ [
93
+ "\n".join([f"[bold blue]{o[0]}[/bold blue]" for o in options]),
94
+ "\n".join([f"[bold green]{o[1]}[/bold green]" for o in options]),
95
+ "\n".join([" " + o[2] for o in options]),
96
+ ],
97
+ padding=(0, 2),
98
+ ),
99
+ title="[dim]Options[/dim]",
100
+ title_align="left",
101
+ padding=(0, 3),
102
+ )
103
+ )
104
+
105
+
106
+ def parse_args() -> tuple[str | None, list[str]]:
107
+ p = argparse.ArgumentParser(add_help=False, exit_on_error=False)
108
+
109
+ p.add_argument("--help", "-h", action="store_true")
110
+ p.add_argument("--yes", "-y", action="store_true")
111
+ p.add_argument("--quiet", "-q", action="store_true")
112
+ p.add_argument("--think", action="store_true")
113
+ p.add_argument("--model", "-m", type=str, default=None)
114
+ p.add_argument("PROMPT_OR_FILE", nargs="?", default=None)
115
+ p.add_argument("ARGS", nargs=argparse.REMAINDER)
116
+
117
+ try:
118
+ args = p.parse_args()
119
+ except argparse.ArgumentError as e:
120
+ rich.print(f"[bold red]Error:[/bold red] {str(e)}")
121
+ print_help()
122
+ sys.exit(1)
123
+
124
+ if args.help:
125
+ print_help()
126
+ sys.exit(0)
127
+
128
+ CLI_OPTIONS.yes = args.yes
129
+ CLI_OPTIONS.quiet = args.quiet
130
+
131
+ if args.model:
132
+ if args.think:
133
+ CONFIG.think_model = args.model
134
+ else:
135
+ CONFIG.model = args.model
136
+
137
+ if args.think:
138
+ CLI_OPTIONS.think = True
139
+
140
+ prompt = args.PROMPT_OR_FILE.strip() if args.PROMPT_OR_FILE else None
141
+
142
+ if prompt == "":
143
+ prompt = None
144
+
145
+ return prompt, (args.ARGS or [])
146
+
147
+
148
+ def main():
149
+ # dotenv.load_dotenv()
150
+ prompt, args = parse_args()
151
+
152
+ if CONFIG.api_key is None:
153
+ if key := os.getenv("OPENROUTER_API_KEY"):
154
+ CONFIG.api_key = key
155
+ else:
156
+ rich.print(
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)
160
+ try:
161
+ asyncio.run(start_session(prompt, args))
162
+ except (KeyboardInterrupt, EOFError):
163
+ rich.print("\n[red]Aborted.[/red]")
164
+ sys.exit(1)
autosh/md.py ADDED
@@ -0,0 +1,394 @@
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
+ if not outer_is_dim:
146
+ self.print("\x1b[22m")
147
+ if find("bold") is not None or outer_is_bold:
148
+ self.print("\x1b[1m")
149
+ styles = styles[:i]
150
+ else:
151
+ self.print("\x1b[2m")
152
+ styles.append("code")
153
+ # Bold
154
+ case "*" if (
155
+ not_code and await self.check("**") and not find_italic_first()
156
+ ):
157
+ await self.next()
158
+ await self.next()
159
+ # print(">", styles, find("bold"))
160
+ if (i := find("bold")) is not None:
161
+ if not outer_is_bold:
162
+ self.print("\x1b[22m")
163
+ styles = styles[:i]
164
+ else:
165
+ self.print("\x1b[1m")
166
+ styles.append("bold")
167
+ case "_" if (
168
+ not_code and await self.check("__") and not find_italic_first()
169
+ ):
170
+ await self.next()
171
+ await self.next()
172
+ if (i := find("bold")) is not None:
173
+ if not outer_is_bold:
174
+ self.print("\x1b[22m")
175
+ styles = styles[:i]
176
+ else:
177
+ self.print("\x1b[1m")
178
+ styles.append("bold")
179
+ # Italic
180
+ case "*" | "_" if (
181
+ not_code
182
+ and not await self.check("* ")
183
+ and not await self.check("_ ")
184
+ ):
185
+ await self.next()
186
+ if (i := find("italic")) is not None:
187
+ if not outer_is_italic:
188
+ self.print("\x1b[23m")
189
+ styles = styles[:i]
190
+ # print(styles, await self.check("**"))
191
+ else:
192
+ self.print("\x1b[3m")
193
+ styles.append("italic")
194
+ # Strike through
195
+ case "~" if not_code and await self.check("~~"):
196
+ await self.next()
197
+ await self.next()
198
+ if (i := find("strike")) is not None:
199
+ self.print("\x1b[29m")
200
+ styles = styles[:i]
201
+ else:
202
+ self.print("\x1b[9m")
203
+ styles.append("strike")
204
+ case _:
205
+ self.print(c)
206
+ await self.next()
207
+
208
+ async def parse_heading(self):
209
+ hashes = 0
210
+ while True:
211
+ c = await self.next()
212
+ if c == "#":
213
+ hashes += 1
214
+ else:
215
+ break
216
+ # Start control
217
+ match hashes:
218
+ case 1:
219
+ self.print("\x1b[45;1;2m") # Magenta background, bold, dim
220
+ self.print("#" * hashes)
221
+ self.print(" \x1b[22;1m") # Reset dim
222
+ await self.parse_single_line_text(outer_is_bold=True)
223
+ case 2:
224
+ self.print("\x1b[35;1;2;4m") # Magenta foreground, bold, dim, underline
225
+ self.print("#" * hashes)
226
+ self.print(" \x1b[22m\x1b[1m") # Reset dim
227
+ await self.parse_single_line_text(outer_is_bold=True)
228
+ case 3:
229
+ self.print("\x1b[35;1;2m") # Magenta foreground, bold, dim
230
+ self.print("#" * hashes)
231
+ self.print(" \x1b[22m\x1b[1m") # Reset dim
232
+ await self.parse_single_line_text(outer_is_bold=True)
233
+ case 4:
234
+ self.print("\x1b[35;2;3m") # Magenta foreground, dim, italic
235
+ self.print("#" * hashes)
236
+ self.print(" \x1b[22m") # Reset dim
237
+ await self.parse_single_line_text(outer_is_italic=True)
238
+ case _:
239
+ self.print("\x1b[2m") # dim
240
+ self.print("#" * hashes)
241
+ self.print(" \x1b[22m") # Reset dim
242
+ await self.parse_single_line_text()
243
+ # Stream title
244
+
245
+ async def parse_paragraph(self):
246
+ while True:
247
+ await self.parse_single_line_text()
248
+ if self.peek() != "\n" and not await self.check_non_paragraph_block_start():
249
+ await self.next()
250
+ break
251
+ else:
252
+ break
253
+
254
+ async def parse_multiline_code(self):
255
+ # dim
256
+ self.print("\x1b[2m")
257
+ self.print("```")
258
+ await self.next()
259
+ await self.next()
260
+ await self.next()
261
+ while not await self.check("\n```"):
262
+ c = await self.next()
263
+ if c is None:
264
+ self.print("\n")
265
+ return
266
+ self.print(c)
267
+ self.print("\n```\n")
268
+ await self.next()
269
+ await self.next()
270
+ await self.next()
271
+ await self.next()
272
+
273
+ async def parse_list(self, ordered: bool):
274
+ indents = [0]
275
+ counter = [1]
276
+ # first item
277
+ if ordered:
278
+ self.print("1. ")
279
+ await self.next()
280
+ else:
281
+ self.print("• ")
282
+ await self.next()
283
+ await self.parse_single_line_text()
284
+ while True:
285
+ indent = 0
286
+ while self.peek() in [" ", "\t", "\n"]:
287
+ if self.peek() in [" ", "\t"]:
288
+ indent += 1
289
+ if self.peek() == "\n":
290
+ indent = 0
291
+ await self.next()
292
+ if self.peek() is None:
293
+ return
294
+ if ordered and not await self.__check_ordered_list_label():
295
+ return
296
+ if not ordered and not await self.__check_unordered_list_label():
297
+ return
298
+ if not ordered:
299
+ await self.next()
300
+ else:
301
+ while self.peek() is not None and self.peek() != ".":
302
+ await self.next()
303
+ await self.next()
304
+
305
+ depth = None
306
+ for i in range(len(indents) - 1):
307
+ if indents[i] <= indent and indents[i + 1] > indent:
308
+ depth = i
309
+ break
310
+ if depth is None and indents[-1] + 2 <= indent:
311
+ # indent one more level
312
+ indents.append(indent)
313
+ depth = len(indents) - 1
314
+ counter.append(1)
315
+ elif depth is None:
316
+ # same as last level
317
+ depth = len(indents) - 1
318
+ counter[depth] += 1
319
+ else:
320
+ # dedent
321
+ indents = indents[: depth + 1]
322
+ counter = counter[: depth + 1]
323
+ counter[depth] += 1
324
+ if not ordered:
325
+ self.print(" " * depth + "• ")
326
+ else:
327
+ self.print(" " * depth + str(counter[depth]) + ". ")
328
+ await self.parse_single_line_text()
329
+
330
+ async def parse_blockquote(self):
331
+ while True:
332
+ while self.peek() in [" ", "\t"]:
333
+ await self.next()
334
+ if self.peek() != ">":
335
+ break
336
+ await self.next()
337
+ self.print("\x1b[1;2m|\x1b[22;2m ")
338
+ await self.parse_single_line_text(outer_is_dim=True)
339
+
340
+ async def parse_doc(self):
341
+ self.__buf = await self.stream.__anext__()
342
+ start = True
343
+ while True:
344
+ # Remove leading spaces and empty lines
345
+ indent = 0
346
+ while self.peek() in [" ", "\t", "\n"]:
347
+ if self.peek() in [" ", "\t"]:
348
+ indent += 1
349
+ if self.peek() == "\n":
350
+ indent = 0
351
+ await self.next()
352
+ if self.peek() is None:
353
+ break
354
+ if not start:
355
+ self.print("\n")
356
+ start = False
357
+ match c := self.peek():
358
+ case None:
359
+ break
360
+ # Heading
361
+ case "#":
362
+ await self.parse_heading()
363
+ # Code
364
+ case "`" if await self.check("```"):
365
+ await self.parse_multiline_code()
366
+ # Separator
367
+ case _ if await self.check("---"):
368
+ await self.next()
369
+ await self.next()
370
+ await self.next()
371
+ width = min(os.get_terminal_size().columns, 80)
372
+ self.print("\x1b[2m" + "─" * width + "\x1b[22m\n")
373
+ # Unordered list
374
+ case _ if await self.__check_unordered_list_label():
375
+ await self.parse_list(False)
376
+ # Ordered list
377
+ case _ if await self.__check_ordered_list_label():
378
+ await self.parse_list(True)
379
+ # Blockquote
380
+ case ">":
381
+ await self.parse_blockquote()
382
+ # Normal paragraph
383
+ case _:
384
+ await self.parse_paragraph()
385
+ self.print("\x1b[0m\x1b[0m\x1b[0m") # Reset all
386
+ self.print("\x1b[0m") # Reset all
387
+
388
+ def __await__(self):
389
+ return self.parse_doc().__await__()
390
+
391
+
392
+ async def stream_md(stream: AsyncGenerator[str, None]):
393
+ mp = MarkdowmPrinter(stream)
394
+ await mp.parse_doc()