aiscript 1.0.0__tar.gz → 1.1.0__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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiscript
3
- Version: 1.0.0
4
- Summary: JIT-compile plain-English intents into cached, reviewed shell scripts via an LLM.
3
+ Version: 1.1.0
4
+ Summary: JIT-compile plain-English intents into cached, reviewed shell or Python scripts via an LLM.
5
5
  Author: Daniel Illescas Romero
6
6
  Author-email: Daniel Illescas Romero <illescas.daniel@protonmail.com>
7
7
  License-Expression: MIT
@@ -17,8 +17,10 @@ Description-Content-Type: text/markdown
17
17
  [![PyPI version](https://img.shields.io/pypi/v/aiscript)](https://pypi.org/project/aiscript/)
18
18
  [![Python versions](https://img.shields.io/pypi/pyversions/aiscript)](https://pypi.org/project/aiscript/)
19
19
 
20
- Describe what you want in plain English and `aiscript` asks an LLM to write a shell script
21
- (Bash on Linux/macOS, PowerShell on Windows), caches it, and runs it. The next run is instant:
20
+ Describe what you want in plain English and `aiscript` asks an LLM to write a script, caches
21
+ it, and runs it. The model picks the language: your native shell (Bash on Linux/macOS, PowerShell
22
+ on Windows) or Python when that fits better. Python scripts that need third-party packages get
23
+ their own virtual environment. The next run is instant:
22
24
  no network, no config. Before anything risky is saved or run, every generated script is checked
23
25
  by a built-in static scan and, optionally, by a decision model: [Jev](https://docs.typesafe.ai)
24
26
  by TypeSafe AI (hosted) or a local model served by [Ollaya](https://ollaya.dev). Risky scripts
@@ -34,7 +36,19 @@ pipx install aiscript
34
36
  pip install aiscript
35
37
  ```
36
38
 
37
- Requires Python 3.10+. To work on aiscript itself, see [docs/development.md](docs/development.md).
39
+ Requires Python 3.10+.
40
+
41
+ To upgrade to the latest version, use the same tool you installed with:
42
+
43
+ ```sh
44
+ uv tool upgrade aiscript
45
+ # or
46
+ pipx upgrade aiscript
47
+ # or
48
+ pip install --upgrade aiscript
49
+ ```
50
+
51
+ Your config, prompts and cached scripts in `~/.config/aiscript/` are kept. To work on aiscript itself, see [docs/development.md](docs/development.md).
38
52
 
39
53
  ## Quickstart
40
54
 
@@ -135,17 +149,43 @@ variable, the legacy `secrets.yaml`): [docs/configuration.md](docs/configuration
135
149
 
136
150
  Command names must match `[A-Za-z0-9][A-Za-z0-9_-]*`.
137
151
 
152
+ ## Shell or Python
153
+
154
+ The model is told your OS, architecture, native shell, the Python version it will run under and
155
+ whether [uv](https://docs.astral.sh/uv/) is installed, and chooses the language: the shell for thin
156
+ wrappers around CLI tools and pipelines, Python for parsing JSON/CSV/HTML, data processing, HTTP
157
+ APIs and anything that would be fragile in shell. Say "in python" or "as a shell script" in your
158
+ description to ask for one; the model follows it unless it is clearly a poor fit (and then notes
159
+ why in the script header). `aiscript --list` shows each command's language.
160
+
161
+ - **Standard-library Python** is saved as `functions/NAME.py` and run with the system `python3`
162
+ (`py -3` on Windows), or with aiscript's own interpreter if there is none.
163
+ - **Python with dependencies** declares them in an inline
164
+ [PEP 723](https://peps.python.org/pep-0723/) block. After you approve (any dependency requires a
165
+ `[y/N]` approval that lists the packages), aiscript creates `functions/NAME/` with `main.py` and
166
+ a private `.venv`: with uv (`pyproject.toml` + `uv.lock`, `uv sync`) or otherwise with
167
+ `python -m venv` + `pip install -r requirements.txt`. The script always runs with that venv's
168
+ interpreter, so later runs are offline. A rebuild with the same dependencies reuses the venv; a
169
+ failed install keeps the previous version. Without uv, Debian/Ubuntu need `python3-venv`.
170
+
138
171
  ## How it works
139
172
 
140
173
  ```
141
174
  ~/.config/aiscript/ # or $XDG_CONFIG_HOME/aiscript, or $AISCRIPT_HOME
142
175
  ├── config.yaml # providers and keys
143
176
  ├── prompts/<name>.txt # your intent for each command
144
- └── functions/<name>.sh # generated, reviewed scripts (.ps1 on Windows)
177
+ └── functions/
178
+ ├── <name>.sh # generated, reviewed scripts (.ps1 on Windows)
179
+ ├── <name>.py # standard-library Python
180
+ └── <name>/ # Python with dependencies
181
+ ├── main.py
182
+ ├── .venv/
183
+ └── pyproject.toml + uv.lock (or requirements.txt)
145
184
  ```
146
185
 
147
186
  The first run of `NAME` saves your description to `prompts/NAME.txt`, asks the LLM for a script,
148
- reviews it, and stores it in `functions/`. Later runs just execute the cached script. If the
187
+ reviews it, and stores it in `functions/` (only one build per command: switching language
188
+ replaces the old one). Later runs just execute the cached script. If the
149
189
  prompt is newer than the script (after `--edit` or a manual change), the script is rebuilt on the
150
190
  next run. A `--rebuild` that fails or is rejected keeps the previous script.
151
191
 
@@ -3,8 +3,10 @@
3
3
  [![PyPI version](https://img.shields.io/pypi/v/aiscript)](https://pypi.org/project/aiscript/)
4
4
  [![Python versions](https://img.shields.io/pypi/pyversions/aiscript)](https://pypi.org/project/aiscript/)
5
5
 
6
- Describe what you want in plain English and `aiscript` asks an LLM to write a shell script
7
- (Bash on Linux/macOS, PowerShell on Windows), caches it, and runs it. The next run is instant:
6
+ Describe what you want in plain English and `aiscript` asks an LLM to write a script, caches
7
+ it, and runs it. The model picks the language: your native shell (Bash on Linux/macOS, PowerShell
8
+ on Windows) or Python when that fits better. Python scripts that need third-party packages get
9
+ their own virtual environment. The next run is instant:
8
10
  no network, no config. Before anything risky is saved or run, every generated script is checked
9
11
  by a built-in static scan and, optionally, by a decision model: [Jev](https://docs.typesafe.ai)
10
12
  by TypeSafe AI (hosted) or a local model served by [Ollaya](https://ollaya.dev). Risky scripts
@@ -20,7 +22,19 @@ pipx install aiscript
20
22
  pip install aiscript
21
23
  ```
22
24
 
23
- Requires Python 3.10+. To work on aiscript itself, see [docs/development.md](docs/development.md).
25
+ Requires Python 3.10+.
26
+
27
+ To upgrade to the latest version, use the same tool you installed with:
28
+
29
+ ```sh
30
+ uv tool upgrade aiscript
31
+ # or
32
+ pipx upgrade aiscript
33
+ # or
34
+ pip install --upgrade aiscript
35
+ ```
36
+
37
+ Your config, prompts and cached scripts in `~/.config/aiscript/` are kept. To work on aiscript itself, see [docs/development.md](docs/development.md).
24
38
 
25
39
  ## Quickstart
26
40
 
@@ -121,17 +135,43 @@ variable, the legacy `secrets.yaml`): [docs/configuration.md](docs/configuration
121
135
 
122
136
  Command names must match `[A-Za-z0-9][A-Za-z0-9_-]*`.
123
137
 
138
+ ## Shell or Python
139
+
140
+ The model is told your OS, architecture, native shell, the Python version it will run under and
141
+ whether [uv](https://docs.astral.sh/uv/) is installed, and chooses the language: the shell for thin
142
+ wrappers around CLI tools and pipelines, Python for parsing JSON/CSV/HTML, data processing, HTTP
143
+ APIs and anything that would be fragile in shell. Say "in python" or "as a shell script" in your
144
+ description to ask for one; the model follows it unless it is clearly a poor fit (and then notes
145
+ why in the script header). `aiscript --list` shows each command's language.
146
+
147
+ - **Standard-library Python** is saved as `functions/NAME.py` and run with the system `python3`
148
+ (`py -3` on Windows), or with aiscript's own interpreter if there is none.
149
+ - **Python with dependencies** declares them in an inline
150
+ [PEP 723](https://peps.python.org/pep-0723/) block. After you approve (any dependency requires a
151
+ `[y/N]` approval that lists the packages), aiscript creates `functions/NAME/` with `main.py` and
152
+ a private `.venv`: with uv (`pyproject.toml` + `uv.lock`, `uv sync`) or otherwise with
153
+ `python -m venv` + `pip install -r requirements.txt`. The script always runs with that venv's
154
+ interpreter, so later runs are offline. A rebuild with the same dependencies reuses the venv; a
155
+ failed install keeps the previous version. Without uv, Debian/Ubuntu need `python3-venv`.
156
+
124
157
  ## How it works
125
158
 
126
159
  ```
127
160
  ~/.config/aiscript/ # or $XDG_CONFIG_HOME/aiscript, or $AISCRIPT_HOME
128
161
  ├── config.yaml # providers and keys
129
162
  ├── prompts/<name>.txt # your intent for each command
130
- └── functions/<name>.sh # generated, reviewed scripts (.ps1 on Windows)
163
+ └── functions/
164
+ ├── <name>.sh # generated, reviewed scripts (.ps1 on Windows)
165
+ ├── <name>.py # standard-library Python
166
+ └── <name>/ # Python with dependencies
167
+ ├── main.py
168
+ ├── .venv/
169
+ └── pyproject.toml + uv.lock (or requirements.txt)
131
170
  ```
132
171
 
133
172
  The first run of `NAME` saves your description to `prompts/NAME.txt`, asks the LLM for a script,
134
- reviews it, and stores it in `functions/`. Later runs just execute the cached script. If the
173
+ reviews it, and stores it in `functions/` (only one build per command: switching language
174
+ replaces the old one). Later runs just execute the cached script. If the
135
175
  prompt is newer than the script (after `--edit` or a manual change), the script is rebuilt on the
136
176
  next run. A `--rebuild` that fails or is rejected keeps the previous script.
137
177
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "aiscript"
3
- version = "1.0.0"
4
- description = "JIT-compile plain-English intents into cached, reviewed shell scripts via an LLM."
3
+ version = "1.1.0"
4
+ description = "JIT-compile plain-English intents into cached, reviewed shell or Python scripts via an LLM."
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "aiscript"
3
- version = "1.0.0"
4
- description = "JIT-compile plain-English intents into cached, reviewed shell scripts via an LLM."
3
+ version = "1.1.0"
4
+ description = "JIT-compile plain-English intents into cached, reviewed shell or Python scripts via an LLM."
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
@@ -1,3 +1,3 @@
1
1
  """aiscript: JIT-compile plain-English intents into cached shell scripts."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.1.0"
@@ -4,11 +4,12 @@ from __future__ import annotations
4
4
 
5
5
  import contextlib
6
6
  import os
7
+ import shutil
7
8
  import sys
8
9
  import tempfile
9
10
  from pathlib import Path
10
11
 
11
- from aiscript import llm, paths, reviewer
12
+ from aiscript import llm, paths, pysetup, reviewer
12
13
  from aiscript.config import Settings
13
14
  from aiscript.errors import AiscriptError
14
15
 
@@ -44,17 +45,26 @@ def build(name: str, settings: Settings) -> Path:
44
45
  print(f"Generating {name!r} with {gen.name}/{gen.model}...", file=sys.stderr)
45
46
  script = llm.generate_script(gen, name, intent)
46
47
 
47
- shell = paths.shell_kind()
48
- result = reviewer.review(script, shell, settings.reviewer)
49
- if not reviewer.confirm(script, result):
48
+ result = reviewer.review(script.code, script.language, settings.reviewer, script.dependencies)
49
+ if not reviewer.confirm(script.code, result):
50
50
  raise AiscriptError("Script rejected; nothing saved.")
51
51
 
52
52
  paths.ensure_dirs()
53
- target = paths.function_path(name)
53
+ if script.dependencies:
54
+ target = pysetup.install(name, script.code, script.dependencies)
55
+ else:
56
+ target = paths.function_target(name, script.language, has_deps=False)
57
+ _write_file(name, target, script.code)
58
+ _remove_other_artifacts(name, target)
59
+ print(f"Saved {target}", file=sys.stderr)
60
+ return target
61
+
62
+
63
+ def _write_file(name: str, target: Path, code: str) -> None:
54
64
  fd, tmp_name = tempfile.mkstemp(dir=paths.functions_dir(), prefix=f".{name}.", suffix=".tmp")
55
65
  try:
56
66
  with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
57
- fh.write(script)
67
+ fh.write(code)
58
68
  if not paths.is_windows():
59
69
  os.chmod(tmp_name, 0o755)
60
70
  os.replace(tmp_name, target)
@@ -62,5 +72,17 @@ def build(name: str, settings: Settings) -> Path:
62
72
  with contextlib.suppress(OSError):
63
73
  os.unlink(tmp_name)
64
74
  raise
65
- print(f"Saved {target}", file=sys.stderr)
66
- return target
75
+
76
+
77
+ def _remove_other_artifacts(name: str, target: Path) -> None:
78
+ """Drop builds of ``name`` in another language or layout, so only ``target`` remains."""
79
+ for path in paths.function_artifacts(name):
80
+ if path == target or path in target.parents:
81
+ continue
82
+ try:
83
+ if path.is_dir():
84
+ shutil.rmtree(path)
85
+ else:
86
+ path.unlink()
87
+ except OSError as exc:
88
+ print(f"aiscript: warning: could not remove old build {path}: {exc}", file=sys.stderr)
@@ -22,7 +22,7 @@ usage: aiscript NAME [args...] run a command (created/built on first use)
22
22
  aiscript --list list commands
23
23
  aiscript --edit NAME edit a command's prompt (rebuilds on next run)
24
24
  aiscript --rebuild NAME regenerate a command's script (does not run it)
25
- aiscript --remove NAME delete a command's prompt and script
25
+ aiscript --remove NAME delete a command's prompt and script(s)
26
26
  aiscript --help | --version
27
27
 
28
28
  Everything after NAME is passed untouched to the generated script.
@@ -92,12 +92,15 @@ def _rebuild(name: str) -> int:
92
92
 
93
93
  def _remove(name: str) -> int:
94
94
  paths.validate_command_name(name)
95
- targets = [p for p in (paths.prompt_path(name), paths.function_path(name)) if p.exists()]
95
+ targets = [p for p in (paths.prompt_path(name), *paths.function_artifacts(name)) if p.exists()]
96
96
  if not targets:
97
97
  raise AiscriptError(f"No command named {name!r}.")
98
98
  for path in targets:
99
99
  try:
100
- path.unlink()
100
+ if path.is_dir():
101
+ shutil.rmtree(path)
102
+ else:
103
+ path.unlink()
101
104
  except OSError as exc:
102
105
  raise AiscriptError(f"Could not remove {path}: {exc}") from exc
103
106
  _err(f"Removed {path}")
@@ -0,0 +1,120 @@
1
+ """Host environment detection for the generator prompt and the runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import platform
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ _MIN_PYTHON = (3, 8)
15
+ _VERSION_TIMEOUT_SECONDS = 5.0
16
+ _VERSION_RE = re.compile(r"Python (\d+)\.(\d+)\.(\d+)")
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PythonInfo:
21
+ command: list[str] # argv prefix that runs it, e.g. ["/usr/bin/python3"] or ["py", "-3"]
22
+ version: str # e.g. "3.13.1"
23
+ is_system: bool # False when falling back to aiscript's own interpreter (sys.executable)
24
+
25
+
26
+ def _is_windows() -> bool:
27
+ return platform.system() == "Windows"
28
+
29
+
30
+ def _candidates() -> list[list[str]]:
31
+ """Argv prefixes to try, best first (only those whose executable is on PATH)."""
32
+ names = [["py", "-3"], ["python"]] if _is_windows() else [["python3"], ["python"]]
33
+ found: list[list[str]] = []
34
+ for argv in names:
35
+ exe = shutil.which(argv[0])
36
+ if exe:
37
+ found.append([exe, *argv[1:]])
38
+ return found
39
+
40
+
41
+ def python_command() -> list[str]:
42
+ """Fast (PATH lookup only, no subprocess): argv prefix for running a stdlib-only script."""
43
+ candidates = _candidates()
44
+ return candidates[0] if candidates else [sys.executable]
45
+
46
+
47
+ def _probe(command: list[str]) -> str | None:
48
+ """Return the ``X.Y.Z`` version of ``command`` if it is a working Python >= 3.8."""
49
+ try:
50
+ result = subprocess.run(
51
+ [*command, "--version"],
52
+ capture_output=True,
53
+ text=True,
54
+ timeout=_VERSION_TIMEOUT_SECONDS,
55
+ check=False,
56
+ )
57
+ except (OSError, subprocess.SubprocessError):
58
+ return None
59
+ if result.returncode != 0: # e.g. the Windows Store stub
60
+ return None
61
+ match = _VERSION_RE.search(f"{result.stdout}\n{result.stderr}")
62
+ if not match:
63
+ return None
64
+ major, minor, patch = (int(g) for g in match.groups())
65
+ if (major, minor) < _MIN_PYTHON:
66
+ return None
67
+ return f"{major}.{minor}.{patch}"
68
+
69
+
70
+ @functools.cache
71
+ def find_python() -> PythonInfo:
72
+ """System Python with its version (runs ``--version``); falls back to sys.executable."""
73
+ for command in _candidates():
74
+ version = _probe(command)
75
+ if version:
76
+ return PythonInfo(command, version, True)
77
+ return PythonInfo([sys.executable], platform.python_version(), False)
78
+
79
+
80
+ def uv_path() -> str | None:
81
+ """Path to the ``uv`` executable, or None."""
82
+ return shutil.which("uv")
83
+
84
+
85
+ def _os_description() -> str:
86
+ system = platform.system() or "Unknown"
87
+ desc = f"{system} {platform.release()}".strip()
88
+ if system == "Linux":
89
+ try:
90
+ for line in Path("/etc/os-release").read_text(encoding="utf-8").splitlines():
91
+ if line.startswith("PRETTY_NAME="):
92
+ pretty = line.split("=", 1)[1].strip().strip("\"'")
93
+ desc += f" ({pretty})"
94
+ break
95
+ except OSError:
96
+ pass
97
+ elif system == "Darwin":
98
+ mac_version = platform.mac_ver()[0]
99
+ if mac_version:
100
+ desc += f" (macOS {mac_version})"
101
+ return desc
102
+
103
+
104
+ def describe() -> str:
105
+ """Multi-line TARGET ENVIRONMENT block for the generator's system prompt."""
106
+ py = find_python()
107
+ if py.is_system:
108
+ shown = " ".join([Path(py.command[0]).name.removesuffix(".exe"), *py.command[1:]])
109
+ python_line = f"{py.version} ({shown})"
110
+ else:
111
+ python_line = f"{py.version} (aiscript's own interpreter; no suitable system Python found)"
112
+ shell = "PowerShell" if _is_windows() else "Bash"
113
+ lines = [
114
+ f"- Host OS: {_os_description()}",
115
+ f"- Architecture: {platform.machine() or 'unknown'}",
116
+ f"- Native shell: {shell}",
117
+ f"- Python: {python_line}",
118
+ f"- uv: {'available' if uv_path() else 'not installed'}",
119
+ ]
120
+ return "\n".join(lines)
@@ -50,7 +50,13 @@ def _command_names() -> list[str]:
50
50
  if pdir.is_dir():
51
51
  names.update(p.stem for p in pdir.glob("*.txt"))
52
52
  if fdir.is_dir():
53
- names.update(p.stem for p in fdir.glob(f"*.{paths.script_ext()}"))
53
+ for p in fdir.iterdir():
54
+ if p.name.startswith("."):
55
+ continue
56
+ if p.is_file() and p.suffix in (".sh", ".ps1", ".py"):
57
+ names.add(p.stem)
58
+ elif p.is_dir() and (p / "main.py").is_file():
59
+ names.add(p.name)
54
60
  valid = []
55
61
  for n in names:
56
62
  try:
@@ -61,14 +67,24 @@ def _command_names() -> list[str]:
61
67
  return sorted(valid, key=str.lower)
62
68
 
63
69
 
70
+ def _lang(fpath: Path) -> str:
71
+ """LANG column: sh, ps1, py or py+deps (project dir); blank when not built."""
72
+ if not fpath.exists():
73
+ return ""
74
+ if paths.is_project_main(fpath):
75
+ return "py+deps"
76
+ return fpath.suffix.lstrip(".")
77
+
78
+
64
79
  def list_commands() -> int:
65
80
  """Print a table of commands to stdout; return 0."""
66
- rows: list[tuple[str, str, str]] = []
81
+ rows: list[tuple[str, str, str, str]] = []
67
82
  any_stale = False
68
83
  for name in _command_names():
69
84
  ppath = paths.prompt_path(name)
70
85
  fpath = paths.function_path(name)
71
86
  label = name
87
+ lang = _lang(fpath)
72
88
  if fpath.exists():
73
89
  header = parse_header(fpath)
74
90
  desc, usage = header["description"], header["usage"]
@@ -77,7 +93,7 @@ def list_commands() -> int:
77
93
  any_stale = True
78
94
  else:
79
95
  desc, usage = _NOT_BUILT, ""
80
- rows.append((label, desc, usage))
96
+ rows.append((label, lang, desc, usage))
81
97
 
82
98
  if not rows:
83
99
  print("No commands yet. Try: aiscript <name>", file=sys.stderr)
@@ -85,16 +101,17 @@ def list_commands() -> int:
85
101
 
86
102
  cols = shutil.get_terminal_size(fallback=(100, 24)).columns
87
103
  name_w = max(len("NAME"), *(len(r[0]) for r in rows))
88
- usage_w = min(max(len("USAGE"), *(len(r[2]) for r in rows)), 40)
89
- desc_w = max(20, cols - name_w - usage_w - 4)
90
- desc_w = min(desc_w, max(len("DESCRIPTION"), *(len(r[1]) for r in rows)))
104
+ lang_w = max(len("LANG"), *(len(r[1]) for r in rows))
105
+ usage_w = min(max(len("USAGE"), *(len(r[3]) for r in rows)), 40)
106
+ desc_w = max(20, cols - name_w - lang_w - usage_w - 6)
107
+ desc_w = min(desc_w, max(len("DESCRIPTION"), *(len(r[2]) for r in rows)))
91
108
 
92
- def fmt(a: str, b: str, c: str) -> str:
93
- return f"{a:<{name_w}} {b:<{desc_w}} {c}".rstrip()
109
+ def fmt(a: str, b: str, c: str, d: str) -> str:
110
+ return f"{a:<{name_w}} {b:<{lang_w}} {c:<{desc_w}} {d}".rstrip()
94
111
 
95
- lines = [fmt("NAME", "DESCRIPTION", "USAGE")]
96
- for label, desc, usage in rows:
97
- lines.append(fmt(label, _truncate(desc, desc_w), _truncate(usage, usage_w)))
112
+ lines = [fmt("NAME", "LANG", "DESCRIPTION", "USAGE")]
113
+ for label, lang, desc, usage in rows:
114
+ lines.append(fmt(label, lang, _truncate(desc, desc_w), _truncate(usage, usage_w)))
98
115
  print("\n".join(lines))
99
116
  if any_stale:
100
117
  print(