autosh 0.0.1__py3-none-any.whl → 0.0.3__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.
@@ -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-..." }
autosh/config.py CHANGED
@@ -3,6 +3,8 @@ from pydantic import BaseModel, Field
3
3
  from pathlib import Path
4
4
  import tomllib
5
5
 
6
+ import rich
7
+
6
8
  USER_CONFIG_PATH = Path.home() / ".config" / "autosh" / "config.toml"
7
9
 
8
10
 
@@ -41,11 +43,20 @@ class Config(BaseModel):
41
43
 
42
44
  @staticmethod
43
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())
44
51
  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})
52
+ try:
53
+ doc = tomllib.loads(USER_CONFIG_PATH.read_text())
54
+ main = doc.get("autosh", {})
55
+ plugins = Plugins(**doc.get("plugins", {}))
56
+ config = Config.model_validate({**main, "plugins": plugins})
57
+ except tomllib.TOMLDecodeError as e:
58
+ rich.print(f"[bold red]Error:[/bold red] invalid config file: {e}")
59
+ sys.exit(1)
49
60
  else:
50
61
  config = Config()
51
62
  return config
autosh/main.py CHANGED
@@ -8,7 +8,7 @@ from rich.columns import Columns
8
8
  from rich.panel import Panel
9
9
  import argparse
10
10
 
11
- from autosh.config import CLI_OPTIONS, CONFIG
11
+ from autosh.config import CLI_OPTIONS, CONFIG, USER_CONFIG_PATH
12
12
  from .session import Session
13
13
  import sys
14
14
 
@@ -149,14 +149,13 @@ def main():
149
149
  # dotenv.load_dotenv()
150
150
  prompt, args = parse_args()
151
151
 
152
+ if key := os.getenv("OPENROUTER_API_KEY"):
153
+ CONFIG.api_key = key
152
154
  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)
155
+ rich.print(
156
+ 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]"
157
+ )
158
+ sys.exit(1)
160
159
  try:
161
160
  asyncio.run(start_session(prompt, args))
162
161
  except (KeyboardInterrupt, EOFError):
autosh/md/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from typing import AsyncGenerator
2
+
3
+ from autosh.md.printer import StreamedMarkdownPrinter
4
+
5
+
6
+ async def stream_md(stream: AsyncGenerator[str, None]):
7
+ mp = StreamedMarkdownPrinter(stream)
8
+ await mp.parse_doc()
@@ -0,0 +1,214 @@
1
+ from dataclasses import dataclass, field
2
+ from autosh.md.printer import StreamedMarkdownPrinter
3
+ from autosh.md.state import State
4
+
5
+
6
+ @dataclass
7
+ class Keyword:
8
+ token: str
9
+
10
+ def is_bold(self) -> bool:
11
+ return self.token[0] in ["*", "_"] and len(self.token) == 2
12
+
13
+ def is_italic(self) -> bool:
14
+ return self.token[0] in ["*", "_"] and len(self.token) == 1
15
+
16
+ def is_bold_and_italic(self) -> bool:
17
+ return self.token[0] in ["*", "_"] and len(self.token) > 2
18
+
19
+ def is_bold_or_italic(self) -> bool:
20
+ return self.token[0] in ["*", "_"]
21
+
22
+
23
+ class InvalidState(Exception):
24
+ def __init__(self, token: str):
25
+ super().__init__(token)
26
+ self.token = token
27
+
28
+
29
+ @dataclass
30
+ class InlineScope:
31
+ state: State
32
+
33
+ stack: list[tuple[Keyword, State]] = field(default_factory=list)
34
+
35
+ @property
36
+ def last(self) -> str | None:
37
+ return self.stack[-1][0].token if len(self.stack) > 0 else None
38
+
39
+ @property
40
+ def strike(self) -> bool:
41
+ return self.last == "~~"
42
+
43
+ @property
44
+ def code(self) -> bool:
45
+ return self.last == "`"
46
+
47
+ def enter(self, kind: Keyword):
48
+ match kind:
49
+ case "`":
50
+ state = self.state._enter_scope(dim=True)
51
+ case "~~":
52
+ state = self.state._enter_scope(strike=True)
53
+ case _ if kind.is_bold():
54
+ state = self.state._enter_scope(bold=True)
55
+ case _ if kind.is_italic():
56
+ state = self.state._enter_scope(italic=True)
57
+ case _ if kind.is_bold_and_italic():
58
+ state = self.state._enter_scope(bold=True, italic=True)
59
+ case _:
60
+ state = self.state._enter_scope()
61
+ self.stack.append((kind, state))
62
+
63
+ def exit(self):
64
+ t = self.stack.pop()
65
+ state = t[1]
66
+ self.state._exit_scope(state)
67
+
68
+
69
+ class InlineTextPrinter:
70
+ def __init__(self, p: StreamedMarkdownPrinter, table: bool = False):
71
+ self.p = p
72
+ self.terminator = ["\n", None]
73
+ if table:
74
+ self.terminator.append("|")
75
+
76
+ def emit(self, s: str):
77
+ self.p.emit(s)
78
+
79
+ def peek(self):
80
+ return self.p.peek()
81
+
82
+ async def consume(self, n: int = 1):
83
+ return await self.p.consume(n)
84
+
85
+ async def check(self, s: str, eof: bool | None = None) -> bool:
86
+ return await self.p.check(s, eof)
87
+
88
+ async def parse_inline_unformatted(self):
89
+ # Parse until another "`" or a newline, or EOF
90
+ while not self.peek() in self.terminator:
91
+ c = await self.p.consume()
92
+ self.emit(c)
93
+
94
+ async def parse_inline_code(self):
95
+ self.emit("`")
96
+ # Parse until another "`" or a newline, or EOF
97
+ while not await self.check("`"):
98
+ c = await self.p.consume()
99
+ if c in self.terminator:
100
+ return
101
+ self.emit(c)
102
+ if c == "\\":
103
+ c = await self.p.consume()
104
+ if c in self.terminator:
105
+ return
106
+ self.emit(c)
107
+ self.emit("`")
108
+ await self.p.consume()
109
+
110
+ async def get_next_token(self) -> str | Keyword | None:
111
+ c = self.peek()
112
+ if c in self.terminator:
113
+ return None
114
+ if c == "`":
115
+ await self.consume()
116
+ return Keyword("`")
117
+ if c == "*":
118
+ s = ""
119
+ while self.peek() == "*":
120
+ s += c
121
+ await self.consume()
122
+ return Keyword(s)
123
+ if c == "_":
124
+ s = ""
125
+ while self.peek() == "_":
126
+ s += c
127
+ await self.consume()
128
+ return Keyword(s)
129
+ if c == "~" and await self.check("~~"):
130
+ s = ""
131
+ while self.peek() == "~":
132
+ s += c
133
+ await self.consume()
134
+ return Keyword(s)
135
+ if c == " " or c == "\t":
136
+ s = ""
137
+ while self.peek() == " " or self.peek() == "\t":
138
+ s += c
139
+ await self.consume()
140
+ return s
141
+ s = c or ""
142
+ if c == "\\":
143
+ await self.consume()
144
+ c = self.peek()
145
+ if c in self.terminator:
146
+ return None if len(s) == 0 else s
147
+ s += c or ""
148
+ await self.consume()
149
+ else:
150
+ await self.consume()
151
+ if len(s) == 0:
152
+ return None
153
+ return s
154
+
155
+ async def parse_inline(self, consume_trailing_newline: bool = True):
156
+ last_is_space = False
157
+ start = True
158
+ scope = InlineScope(self.p.state)
159
+
160
+ while True:
161
+ t = await self.get_next_token()
162
+ curr_is_space = False
163
+
164
+ if t is None:
165
+ if consume_trailing_newline:
166
+ if self.peek() == "\n":
167
+ await self.consume()
168
+ self.emit("\n")
169
+ return
170
+ elif isinstance(t, str):
171
+ # Space or normal text
172
+ if t[0] == " ":
173
+ curr_is_space = True
174
+ self.emit(" ")
175
+ else:
176
+ self.emit(t)
177
+ elif t.token == "`":
178
+ # Inline code
179
+ scope.enter(t)
180
+ with self.p.state.style(dim=True):
181
+ await self.parse_inline_code()
182
+ scope.exit()
183
+ elif t.token == "~~":
184
+ # Strike through
185
+ if not scope.strike:
186
+ scope.enter(t)
187
+ self.emit("~~")
188
+ else:
189
+ self.emit("~~")
190
+ scope.exit()
191
+ elif (
192
+ t.is_bold_or_italic()
193
+ and (last_is_space or start)
194
+ and scope.last != t.token
195
+ ):
196
+ # Start bold or italics
197
+ scope.enter(t)
198
+ self.emit(t.token)
199
+ elif (
200
+ t.is_bold_or_italic()
201
+ and self.peek() in [" ", "\t", *self.terminator]
202
+ and scope.last == t.token
203
+ ):
204
+ # End bold or italics
205
+ self.emit(t.token)
206
+ scope.exit()
207
+ else:
208
+ # print(
209
+ # "Invalid token:", t.token, f"[{self.peek()}]", scope.last == t.token
210
+ # )
211
+ raise InvalidState(t.token)
212
+
213
+ last_is_space = curr_is_space
214
+ start = False
autosh/md/printer.py ADDED
@@ -0,0 +1,301 @@
1
+ import os
2
+ from typing import AsyncGenerator, Literal
3
+ import unicodedata
4
+
5
+ from autosh.md.state import Color, State
6
+ from autosh.md.stream import TextStream
7
+
8
+
9
+ class StreamedMarkdownPrinter:
10
+ def __init__(self, gen: AsyncGenerator[str, None]):
11
+ self.stream = TextStream(gen)
12
+ self.state = State()
13
+
14
+ def emit(self, s: str):
15
+ self.state.emit(s)
16
+
17
+ def peek(self):
18
+ return self.stream.peek()
19
+
20
+ async def consume(self, n: int = 1):
21
+ return await self.stream.consume(n)
22
+
23
+ async def check(self, s: str, eof: bool | None = None) -> bool:
24
+ return await self.stream.check(s, eof)
25
+
26
+ async def parse_inline(self, consume_trailing_newline: bool = True):
27
+ from autosh.md.inline_text import InlineTextPrinter
28
+
29
+ itp = InlineTextPrinter(self)
30
+ await itp.parse_inline(consume_trailing_newline)
31
+
32
+ async def parse_heading(self):
33
+ hashes = 0
34
+ while True:
35
+ c = await self.consume()
36
+ if c == "#":
37
+ hashes += 1
38
+ else:
39
+ break
40
+ match hashes:
41
+ case 1:
42
+ with self.state.style(bold=True, bg=Color.MAGENTA):
43
+ with self.state.style(dim=True):
44
+ self.emit("#" * hashes + " ")
45
+ await self.parse_inline(consume_trailing_newline=False)
46
+ case 2:
47
+ with self.state.style(bold=True, underline=True, color=Color.MAGENTA):
48
+ with self.state.style(dim=True):
49
+ self.emit("#" * hashes + " ")
50
+ await self.parse_inline(consume_trailing_newline=False)
51
+ case 3:
52
+ with self.state.style(bold=True, color=Color.MAGENTA):
53
+ with self.state.style(dim=True):
54
+ self.emit("#" * hashes + " ")
55
+ await self.parse_inline(consume_trailing_newline=False)
56
+ case 4:
57
+ with self.state.style(bold=True, italic=True, color=Color.MAGENTA):
58
+ with self.state.style(dim=True):
59
+ self.emit("#" * hashes + " ")
60
+ await self.parse_inline(consume_trailing_newline=False)
61
+ case _:
62
+ with self.state.style(bold=True):
63
+ with self.state.style(dim=True):
64
+ self.emit("#" * hashes + " ")
65
+ await self.parse_inline(consume_trailing_newline=False)
66
+ await self.consume() # consume the newline
67
+ self.emit("\n")
68
+
69
+ async def parse_paragraph(self):
70
+ while True:
71
+ await self.parse_inline()
72
+ if (
73
+ self.peek() != "\n"
74
+ and not await self.stream.non_paragraph_block_start()
75
+ ):
76
+ if self.peek() == "\n":
77
+ await self.consume()
78
+ break
79
+ else:
80
+ break
81
+
82
+ async def parse_multiline_code(self):
83
+ with self.state.style(dim=True):
84
+ self.emit("```")
85
+ await self.consume(3)
86
+ while not await self.check("\n```"):
87
+ c = await self.consume()
88
+ if c is None:
89
+ self.emit("\n")
90
+ return
91
+ self.emit(c)
92
+ self.emit("\n```\n")
93
+ await self.consume(4)
94
+
95
+ async def parse_list(self, ordered: bool):
96
+ indents = [0]
97
+ counter = [1]
98
+ # first item
99
+ if ordered:
100
+ self.emit("1. ")
101
+ await self.consume()
102
+ else:
103
+ self.emit("• ")
104
+ await self.consume()
105
+ await self.parse_inline()
106
+ while True:
107
+ indent = 0
108
+ while self.peek() in [" ", "\t", "\n"]:
109
+ if self.peek() in [" ", "\t"]:
110
+ indent += 1
111
+ if self.peek() == "\n":
112
+ indent = 0
113
+ await self.consume()
114
+ if self.peek() is None:
115
+ return
116
+ if ordered and not await self.stream.ordered_list_label():
117
+ return
118
+ if not ordered and not await self.stream.unordered_list_label():
119
+ return
120
+ if not ordered:
121
+ await self.consume()
122
+ else:
123
+ while self.peek() is not None and self.peek() != ".":
124
+ await self.consume()
125
+ await self.consume()
126
+
127
+ depth = None
128
+ for i in range(len(indents) - 1):
129
+ if indents[i] <= indent and indents[i + 1] > indent:
130
+ depth = i
131
+ break
132
+ if depth is None and indents[-1] + 2 <= indent:
133
+ # indent one more level
134
+ indents.append(indent)
135
+ depth = len(indents) - 1
136
+ counter.append(1)
137
+ elif depth is None:
138
+ # same as last level
139
+ depth = len(indents) - 1
140
+ counter[depth] += 1
141
+ else:
142
+ # dedent
143
+ indents = indents[: depth + 1]
144
+ counter = counter[: depth + 1]
145
+ counter[depth] += 1
146
+ if not ordered:
147
+ self.emit(" " * depth + "• ")
148
+ else:
149
+ self.emit(" " * depth + str(counter[depth]) + ". ")
150
+ await self.parse_inline()
151
+
152
+ async def parse_blockquote(self):
153
+ from autosh.md.inline_text import InlineTextPrinter
154
+
155
+ while True:
156
+ while self.peek() in [" ", "\t"]:
157
+ await self.consume()
158
+ if self.peek() != ">":
159
+ break
160
+ await self.consume()
161
+ with self.state.style(bold=True, dim=True):
162
+ self.emit(">")
163
+ with self.state.style(dim=True):
164
+ itp = InlineTextPrinter(self)
165
+ await itp.parse_inline_unformatted()
166
+ if self.peek() == "\n":
167
+ self.emit("\n")
168
+ await self.consume()
169
+
170
+ async def parse_table(self):
171
+ def str_size(s: str) -> int:
172
+ size = 0
173
+ for c in s:
174
+ match unicodedata.east_asian_width(c):
175
+ case "F" | "W":
176
+ size += 2
177
+ case _:
178
+ size += 1
179
+ return size
180
+
181
+ rows: list[list[str]] = []
182
+ while True:
183
+ if self.peek() != "|":
184
+ break
185
+ s = "|"
186
+ await self.consume()
187
+ while self.peek() not in ["\n", None]:
188
+ s += self.peek() or ""
189
+ await self.consume()
190
+ if self.peek() == "\n":
191
+ await self.consume()
192
+ s = s.strip("|")
193
+ rows.append([c.strip() for c in s.split("|")])
194
+ # not enough rows
195
+ if len(rows) < 2:
196
+ self.emit("| " + " | ".join(rows[0]) + " |\n")
197
+ # do some simple formatting
198
+ cols = len(rows[0])
199
+ col_widths = [0] * cols
200
+ aligns: list[Literal["left", "right", "center"]] = ["left"] * cols
201
+ for i, row in enumerate(rows):
202
+ if i == 1:
203
+ # check for alignment
204
+ for j, c in enumerate(row):
205
+ if c.startswith(":") and c.endswith(":"):
206
+ aligns[j] = "center"
207
+ elif c.startswith(":"):
208
+ aligns[j] = "left"
209
+ elif c.endswith(":"):
210
+ aligns[j] = "right"
211
+ continue
212
+ for j, c in enumerate(row):
213
+ col_widths[j] = max(col_widths[j], str_size(c))
214
+ # print top border
215
+ with self.state.style(dim=True):
216
+ for j, c in enumerate(rows[0]):
217
+ self.emit("┌" if j == 0 else "┬")
218
+ self.emit("─" * (col_widths[j] + 2))
219
+ self.emit("┐\n")
220
+ # print the table
221
+ for i, row in enumerate(rows):
222
+ for j, c in enumerate(row):
223
+ with self.state.style(dim=True):
224
+ self.emit("│" if i != 1 else ("├" if j == 0 else "┼"))
225
+ if i == 1:
226
+ text = "─" * (col_widths[j] + 2)
227
+ else:
228
+ s = str_size(c)
229
+ align = aligns[j] if i != 0 else "center"
230
+ match align:
231
+ case "left":
232
+ text = c + " " * (col_widths[j] - s)
233
+ case "right":
234
+ text = " " * (col_widths[j] - s) + c
235
+ case "center":
236
+ padding = col_widths[j] - s
237
+ text = " " * (padding // 2) + c
238
+ text += " " * (padding - padding // 2)
239
+ text = f" {text} "
240
+ with self.state.style(dim=i == 1):
241
+ self.emit(text)
242
+ with self.state.style(dim=True):
243
+ self.emit("│\n" if i != 1 else "┤\n")
244
+ # print bottom border
245
+ with self.state.style(dim=True):
246
+ for j, c in enumerate(rows[0]):
247
+ self.emit("└" if j == 0 else "┴")
248
+ self.emit("─" * (col_widths[j] + 2))
249
+ self.emit("┘\n")
250
+
251
+ async def parse_doc(self):
252
+ await self.stream.init()
253
+ start = True
254
+ while True:
255
+ # Remove leading spaces and empty lines
256
+ indent = 0
257
+ while self.peek() in [" ", "\t", "\n"]:
258
+ if self.peek() in [" ", "\t"]:
259
+ indent += 1
260
+ if self.peek() == "\n":
261
+ indent = 0
262
+ await self.consume()
263
+ if self.peek() is None:
264
+ break
265
+ if not start:
266
+ self.emit("\n")
267
+ start = False
268
+ match c := self.peek():
269
+ case None:
270
+ break
271
+ # Heading
272
+ case "#":
273
+ await self.parse_heading()
274
+ # Code
275
+ case "`" if await self.check("```"):
276
+ await self.parse_multiline_code()
277
+ # Separator
278
+ case _ if await self.check("---"):
279
+ await self.consume(3)
280
+ width = min(os.get_terminal_size().columns, 80)
281
+ with self.state.style(dim=True):
282
+ self.emit("─" * width)
283
+ # Unordered list
284
+ case _ if await self.stream.unordered_list_label():
285
+ await self.parse_list(False)
286
+ # Ordered list
287
+ case _ if await self.stream.ordered_list_label():
288
+ await self.parse_list(True)
289
+ # Blockquote
290
+ case ">":
291
+ await self.parse_blockquote()
292
+ # Table
293
+ case "|":
294
+ await self.parse_table()
295
+ # Normal paragraph
296
+ case _:
297
+ await self.parse_paragraph()
298
+ self.emit("\x1b[0m") # Reset all
299
+
300
+ def __await__(self):
301
+ return self.parse_doc().__await__()