autosh 0.0.2__tar.gz → 0.0.3__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.
@@ -16,3 +16,4 @@ wheels/
16
16
 
17
17
  # Other files
18
18
  /_*
19
+ .DS_Store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: autosh
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Add your description here
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.13
@@ -31,7 +31,9 @@ As an interactive shell: `ash` (alternatively, `autosh`)
31
31
 
32
32
  Execute a single prompt: `ash "list current directory"`
33
33
 
34
- Process piped data: `cat README.md | ash "summarise"`
34
+ Process piped data:
35
+ * `cat README.md | ash -y "summarise"`
36
+ * `cat in.csv | ash -y -q "double the first numeric column" > out.csv`
35
37
 
36
38
  ## Scripting
37
39
 
@@ -72,6 +74,8 @@ Write "Hello, world" to _test.log
72
74
 
73
75
  # TODO
74
76
 
75
- - [ ] Image generation
76
- - [ ] Image input
77
+ - [ ] Image input, generation, and editing
77
78
  - [ ] RAG for non-text files
79
+ - [ ] Plugin system
80
+ - [ ] MCP support
81
+ - [ ] 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: `cat README.md | ash "summarise"`
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())
@@ -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):
@@ -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
@@ -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__()