aiscript 1.0.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.
aiscript-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Illescas Romero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiscript
3
+ Version: 1.0.0
4
+ Summary: JIT-compile plain-English intents into cached, reviewed shell scripts via an LLM.
5
+ Author: Daniel Illescas Romero
6
+ Author-email: Daniel Illescas Romero <illescas.daniel@protonmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: openai>=3.35.1
10
+ Requires-Dist: pyyaml>=6.0.3
11
+ Requires-Dist: typesafe-sdk>=0.7.5
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+
15
+ # aiscript
16
+
17
+ [![PyPI version](https://img.shields.io/pypi/v/aiscript)](https://pypi.org/project/aiscript/)
18
+ [![Python versions](https://img.shields.io/pypi/pyversions/aiscript)](https://pypi.org/project/aiscript/)
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:
22
+ no network, no config. Before anything risky is saved or run, every generated script is checked
23
+ by a built-in static scan and, optionally, by a decision model: [Jev](https://docs.typesafe.ai)
24
+ by TypeSafe AI (hosted) or a local model served by [Ollaya](https://ollaya.dev). Risky scripts
25
+ are shown to you and need an explicit `[y/N]` approval.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ uv tool install aiscript
31
+ # or
32
+ pipx install aiscript
33
+ # or
34
+ pip install aiscript
35
+ ```
36
+
37
+ Requires Python 3.10+. To work on aiscript itself, see [docs/development.md](docs/development.md).
38
+
39
+ ## Quickstart
40
+
41
+ ```sh
42
+ aiscript --init # creates ~/.config/aiscript/config.yaml
43
+ ```
44
+
45
+ Put your OpenAI and Jev API keys in `~/.config/aiscript/config.yaml` (or export `OPENAI_API_KEY`
46
+ and `TYPESAFE_API_KEY`). The same file is where you pick the models: the one that writes the
47
+ scripts (`default_provider`) and the decision model that reviews them (`reviewer_provider`).
48
+ For a local, keyless review, use Ollaya instead of Jev (see below). Jev keys come from
49
+ <https://console.typesafe.ai/keys>.
50
+
51
+ Environment variables are the alternative to keys in the file:
52
+
53
+ ```sh
54
+ export OPENAI_API_KEY=...
55
+ export TYPESAFE_API_KEY=...
56
+ ```
57
+
58
+ Then run your first command:
59
+
60
+ ```sh
61
+ aiscript sizes # first run: asks "What should 'sizes' do?"
62
+ aiscript sizes ~/Downloads --top 5 # later runs: cached, arguments passed straight through
63
+ ```
64
+
65
+ Everything after the command name is passed untouched to the script (including `--help`).
66
+ `aiscript` messages go to stderr; stdout belongs to the script, and the exit code is the script's.
67
+
68
+ ## Safety review / decision models
69
+
70
+ Every generated script is reviewed **before it is saved**:
71
+
72
+ - A **static regex scan** (always on) flags privilege escalation, recursive or system-wide file
73
+ changes, networking/firewall changes and remote code execution (`curl | sh`, `iex`).
74
+ - A **decision model** (`reviewer_provider`) gives a second opinion: **Jev** (hosted; key from
75
+ <https://console.typesafe.ai/keys>) or **Ollaya** (local and keyless; set
76
+ `reviewer_provider: ollaya`, default model `jevk5:4b` on `localhost:11435`; see
77
+ <https://ollaya.dev>). Comment `reviewer_provider` out to use only the static scan.
78
+
79
+ If either check flags the script, you see the reasons and the code and must answer `[y/N]`;
80
+ declining saves nothing. The review **fails closed**: if the reviewer errors or is unreachable,
81
+ the script is treated as risky. With a non-interactive stdin, risky scripts are refused. This is
82
+ a best-effort safety net, not a sandbox: read what you approve. Details: [docs/safety.md](docs/safety.md).
83
+
84
+ ## Configuration
85
+
86
+ `aiscript --init` writes this starter `~/.config/aiscript/config.yaml` (mode 600; keep it out of git):
87
+
88
+ ```yaml
89
+ # aiscript config. Contains API keys: keep it private (chmod 600) and out of git.
90
+ # Keys can also come from the environment: OPENAI_API_KEY, TYPESAFE_API_KEY, <PROVIDER>_API_KEY.
91
+
92
+ # Provider that writes the scripts (override with $AISCRIPT_PROVIDER).
93
+ default_provider: openai
94
+
95
+ # Decision model that reviews scripts on top of the built-in static scan.
96
+ # Use `ollaya` for a local, keyless review; comment out to use only the static scan.
97
+ reviewer_provider: jev
98
+
99
+ providers:
100
+ # OpenAI (or any OpenAI-compatible API). API key: https://platform.openai.com/api-keys
101
+ openai:
102
+ api_key: "..."
103
+ model: "gpt-6-luna"
104
+ # reasoning_effort: low # none | low | medium | high | xhigh | max (model-dependent)
105
+
106
+ # Jev (TypeSafe AI) safety reviewer. API key: https://console.typesafe.ai/keys
107
+ jev:
108
+ api_key: "..."
109
+
110
+ # Uses Ollaya for a local decision model (https://ollaya.dev), model example: jevk5:4b
111
+ # ollaya:
112
+ # model: "jevk5:4b"
113
+
114
+ # Uses Ollama for a local LLM to create the scripts (set `default_provider: ollama`)
115
+ # ollama:
116
+ # base_url: "http://localhost:11434/v1"
117
+ # model: "qwen3"
118
+ ```
119
+
120
+ Placeholder keys (`"..."`) are ignored, so if you leave them, the environment variables above work as-is.
121
+ All options (provider kinds, `api`, `temperature`, key and model resolution, every environment
122
+ variable, the legacy `secrets.yaml`): [docs/configuration.md](docs/configuration.md).
123
+
124
+ ## Usage
125
+
126
+ | Command | Action |
127
+ |---|---|
128
+ | `aiscript NAME [args...]` | Run a command; created and built on first use |
129
+ | `aiscript --init` | Create config dirs and a `config.yaml` template (never overwrites) |
130
+ | `aiscript --list` | Table of commands, descriptions and usage (`*` = prompt changed, will rebuild) |
131
+ | `aiscript --edit NAME` | Open the prompt in `$VISUAL` / `$EDITOR` (fallback nano/vi, notepad on Windows) |
132
+ | `aiscript --rebuild NAME` | Regenerate the script and print its path; does not run it |
133
+ | `aiscript --remove NAME` | Delete the command's prompt and cached script |
134
+ | `aiscript --help`, `--version` | Help / version |
135
+
136
+ Command names must match `[A-Za-z0-9][A-Za-z0-9_-]*`.
137
+
138
+ ## How it works
139
+
140
+ ```
141
+ ~/.config/aiscript/ # or $XDG_CONFIG_HOME/aiscript, or $AISCRIPT_HOME
142
+ ├── config.yaml # providers and keys
143
+ ├── prompts/<name>.txt # your intent for each command
144
+ └── functions/<name>.sh # generated, reviewed scripts (.ps1 on Windows)
145
+ ```
146
+
147
+ 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
149
+ prompt is newer than the script (after `--edit` or a manual change), the script is rebuilt on the
150
+ next run. A `--rebuild` that fails or is rejected keeps the previous script.
151
+
152
+ ## Docs
153
+
154
+ - [Configuration reference](docs/configuration.md)
155
+ - [Safety review](docs/safety.md)
156
+ - [Development](docs/development.md)
@@ -0,0 +1,142 @@
1
+ # aiscript
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/aiscript)](https://pypi.org/project/aiscript/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/aiscript)](https://pypi.org/project/aiscript/)
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:
8
+ no network, no config. Before anything risky is saved or run, every generated script is checked
9
+ by a built-in static scan and, optionally, by a decision model: [Jev](https://docs.typesafe.ai)
10
+ by TypeSafe AI (hosted) or a local model served by [Ollaya](https://ollaya.dev). Risky scripts
11
+ are shown to you and need an explicit `[y/N]` approval.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ uv tool install aiscript
17
+ # or
18
+ pipx install aiscript
19
+ # or
20
+ pip install aiscript
21
+ ```
22
+
23
+ Requires Python 3.10+. To work on aiscript itself, see [docs/development.md](docs/development.md).
24
+
25
+ ## Quickstart
26
+
27
+ ```sh
28
+ aiscript --init # creates ~/.config/aiscript/config.yaml
29
+ ```
30
+
31
+ Put your OpenAI and Jev API keys in `~/.config/aiscript/config.yaml` (or export `OPENAI_API_KEY`
32
+ and `TYPESAFE_API_KEY`). The same file is where you pick the models: the one that writes the
33
+ scripts (`default_provider`) and the decision model that reviews them (`reviewer_provider`).
34
+ For a local, keyless review, use Ollaya instead of Jev (see below). Jev keys come from
35
+ <https://console.typesafe.ai/keys>.
36
+
37
+ Environment variables are the alternative to keys in the file:
38
+
39
+ ```sh
40
+ export OPENAI_API_KEY=...
41
+ export TYPESAFE_API_KEY=...
42
+ ```
43
+
44
+ Then run your first command:
45
+
46
+ ```sh
47
+ aiscript sizes # first run: asks "What should 'sizes' do?"
48
+ aiscript sizes ~/Downloads --top 5 # later runs: cached, arguments passed straight through
49
+ ```
50
+
51
+ Everything after the command name is passed untouched to the script (including `--help`).
52
+ `aiscript` messages go to stderr; stdout belongs to the script, and the exit code is the script's.
53
+
54
+ ## Safety review / decision models
55
+
56
+ Every generated script is reviewed **before it is saved**:
57
+
58
+ - A **static regex scan** (always on) flags privilege escalation, recursive or system-wide file
59
+ changes, networking/firewall changes and remote code execution (`curl | sh`, `iex`).
60
+ - A **decision model** (`reviewer_provider`) gives a second opinion: **Jev** (hosted; key from
61
+ <https://console.typesafe.ai/keys>) or **Ollaya** (local and keyless; set
62
+ `reviewer_provider: ollaya`, default model `jevk5:4b` on `localhost:11435`; see
63
+ <https://ollaya.dev>). Comment `reviewer_provider` out to use only the static scan.
64
+
65
+ If either check flags the script, you see the reasons and the code and must answer `[y/N]`;
66
+ declining saves nothing. The review **fails closed**: if the reviewer errors or is unreachable,
67
+ the script is treated as risky. With a non-interactive stdin, risky scripts are refused. This is
68
+ a best-effort safety net, not a sandbox: read what you approve. Details: [docs/safety.md](docs/safety.md).
69
+
70
+ ## Configuration
71
+
72
+ `aiscript --init` writes this starter `~/.config/aiscript/config.yaml` (mode 600; keep it out of git):
73
+
74
+ ```yaml
75
+ # aiscript config. Contains API keys: keep it private (chmod 600) and out of git.
76
+ # Keys can also come from the environment: OPENAI_API_KEY, TYPESAFE_API_KEY, <PROVIDER>_API_KEY.
77
+
78
+ # Provider that writes the scripts (override with $AISCRIPT_PROVIDER).
79
+ default_provider: openai
80
+
81
+ # Decision model that reviews scripts on top of the built-in static scan.
82
+ # Use `ollaya` for a local, keyless review; comment out to use only the static scan.
83
+ reviewer_provider: jev
84
+
85
+ providers:
86
+ # OpenAI (or any OpenAI-compatible API). API key: https://platform.openai.com/api-keys
87
+ openai:
88
+ api_key: "..."
89
+ model: "gpt-6-luna"
90
+ # reasoning_effort: low # none | low | medium | high | xhigh | max (model-dependent)
91
+
92
+ # Jev (TypeSafe AI) safety reviewer. API key: https://console.typesafe.ai/keys
93
+ jev:
94
+ api_key: "..."
95
+
96
+ # Uses Ollaya for a local decision model (https://ollaya.dev), model example: jevk5:4b
97
+ # ollaya:
98
+ # model: "jevk5:4b"
99
+
100
+ # Uses Ollama for a local LLM to create the scripts (set `default_provider: ollama`)
101
+ # ollama:
102
+ # base_url: "http://localhost:11434/v1"
103
+ # model: "qwen3"
104
+ ```
105
+
106
+ Placeholder keys (`"..."`) are ignored, so if you leave them, the environment variables above work as-is.
107
+ All options (provider kinds, `api`, `temperature`, key and model resolution, every environment
108
+ variable, the legacy `secrets.yaml`): [docs/configuration.md](docs/configuration.md).
109
+
110
+ ## Usage
111
+
112
+ | Command | Action |
113
+ |---|---|
114
+ | `aiscript NAME [args...]` | Run a command; created and built on first use |
115
+ | `aiscript --init` | Create config dirs and a `config.yaml` template (never overwrites) |
116
+ | `aiscript --list` | Table of commands, descriptions and usage (`*` = prompt changed, will rebuild) |
117
+ | `aiscript --edit NAME` | Open the prompt in `$VISUAL` / `$EDITOR` (fallback nano/vi, notepad on Windows) |
118
+ | `aiscript --rebuild NAME` | Regenerate the script and print its path; does not run it |
119
+ | `aiscript --remove NAME` | Delete the command's prompt and cached script |
120
+ | `aiscript --help`, `--version` | Help / version |
121
+
122
+ Command names must match `[A-Za-z0-9][A-Za-z0-9_-]*`.
123
+
124
+ ## How it works
125
+
126
+ ```
127
+ ~/.config/aiscript/ # or $XDG_CONFIG_HOME/aiscript, or $AISCRIPT_HOME
128
+ ├── config.yaml # providers and keys
129
+ ├── prompts/<name>.txt # your intent for each command
130
+ └── functions/<name>.sh # generated, reviewed scripts (.ps1 on Windows)
131
+ ```
132
+
133
+ 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
135
+ prompt is newer than the script (after `--edit` or a manual change), the script is rebuilt on the
136
+ next run. A `--rebuild` that fails or is rejected keeps the previous script.
137
+
138
+ ## Docs
139
+
140
+ - [Configuration reference](docs/configuration.md)
141
+ - [Safety review](docs/safety.md)
142
+ - [Development](docs/development.md)
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "aiscript"
3
+ version = "1.0.0"
4
+ description = "JIT-compile plain-English intents into cached, reviewed shell scripts via an LLM."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "openai>=3.35.1",
11
+ "pyyaml>=6.0.3",
12
+ "typesafe-sdk>=0.7.5",
13
+ ]
14
+
15
+ [[project.authors]]
16
+ name = "Daniel Illescas Romero"
17
+ email = "illescas.daniel@protonmail.com"
18
+
19
+ [project.scripts]
20
+ aiscript = "aiscript.cli:main"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.13.1,<0.14.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "mypy>=2.4.0",
29
+ "pytest>=9.1.1",
30
+ "ruff>=0.17.1",
31
+ ]
32
+
33
+ [tool.ruff]
34
+ line-length = 100
35
+ target-version = "py310"
36
+
37
+ [tool.ruff.lint]
38
+ select = [
39
+ "E",
40
+ "F",
41
+ "I",
42
+ "UP",
43
+ "B",
44
+ "ANN",
45
+ ]
46
+
47
+ [tool.ruff.lint.flake8-annotations]
48
+ allow-star-arg-any = true
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+
53
+ [tool.mypy]
54
+ python_version = "3.10"
55
+ ignore_missing_imports = true
56
+ warn_unused_ignores = true
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "aiscript"
3
+ version = "1.0.0"
4
+ description = "JIT-compile plain-English intents into cached, reviewed shell scripts via an LLM."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Daniel Illescas Romero", email = "illescas.daniel@protonmail.com" }
10
+ ]
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "openai>=3.35.1",
14
+ "pyyaml>=6.0.3",
15
+ "typesafe-sdk>=0.7.5",
16
+ ]
17
+
18
+ [project.scripts]
19
+ aiscript = "aiscript.cli:main"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.13.1,<0.14.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "mypy>=2.4.0",
28
+ "pytest>=9.1.1",
29
+ "ruff>=0.17.1",
30
+ ]
31
+
32
+ [tool.ruff]
33
+ line-length = 100
34
+ target-version = "py310"
35
+
36
+ [tool.ruff.lint]
37
+ select = ["E", "F", "I", "UP", "B", "ANN"]
38
+
39
+ [tool.ruff.lint.flake8-annotations]
40
+ # Test doubles forward arbitrary arguments as `*args: Any, **kwargs: Any`.
41
+ allow-star-arg-any = true
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+
46
+ [tool.mypy]
47
+ python_version = "3.10"
48
+ ignore_missing_imports = true
49
+ warn_unused_ignores = true
@@ -0,0 +1,3 @@
1
+ """aiscript: JIT-compile plain-English intents into cached shell scripts."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m aiscript``."""
2
+
3
+ import sys
4
+
5
+ from aiscript.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,66 @@
1
+ """Staleness check and build pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from aiscript import llm, paths, reviewer
12
+ from aiscript.config import Settings
13
+ from aiscript.errors import AiscriptError
14
+
15
+
16
+ def needs_build(name: str) -> bool:
17
+ """True if the function is missing or older than its prompt."""
18
+ fpath = paths.function_path(name)
19
+ if not fpath.exists():
20
+ return True
21
+ ppath = paths.prompt_path(name)
22
+ if not ppath.exists():
23
+ return False
24
+ return ppath.stat().st_mtime_ns > fpath.stat().st_mtime_ns
25
+
26
+
27
+ def _read_intent(name: str) -> str:
28
+ ppath = paths.prompt_path(name)
29
+ try:
30
+ intent = ppath.read_text(encoding="utf-8").strip()
31
+ except FileNotFoundError:
32
+ raise AiscriptError(
33
+ f"No prompt for {name!r}. Run `aiscript {name}` to create it."
34
+ ) from None
35
+ if not intent:
36
+ raise AiscriptError(f"Prompt for {name!r} is empty: {ppath}")
37
+ return intent
38
+
39
+
40
+ def build(name: str, settings: Settings) -> Path:
41
+ """Generate, review and atomically write the script for ``name``."""
42
+ intent = _read_intent(name)
43
+ gen = settings.generator
44
+ print(f"Generating {name!r} with {gen.name}/{gen.model}...", file=sys.stderr)
45
+ script = llm.generate_script(gen, name, intent)
46
+
47
+ shell = paths.shell_kind()
48
+ result = reviewer.review(script, shell, settings.reviewer)
49
+ if not reviewer.confirm(script, result):
50
+ raise AiscriptError("Script rejected; nothing saved.")
51
+
52
+ paths.ensure_dirs()
53
+ target = paths.function_path(name)
54
+ fd, tmp_name = tempfile.mkstemp(dir=paths.functions_dir(), prefix=f".{name}.", suffix=".tmp")
55
+ try:
56
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
57
+ fh.write(script)
58
+ if not paths.is_windows():
59
+ os.chmod(tmp_name, 0o755)
60
+ os.replace(tmp_name, target)
61
+ except BaseException:
62
+ with contextlib.suppress(OSError):
63
+ os.unlink(tmp_name)
64
+ raise
65
+ print(f"Saved {target}", file=sys.stderr)
66
+ return target
@@ -0,0 +1,163 @@
1
+ """Command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import shlex
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+
12
+ from aiscript import __version__, builder, paths
13
+ from aiscript.config import load_settings
14
+ from aiscript.errors import AiscriptError
15
+ from aiscript.init_cmd import run_init
16
+ from aiscript.listing import list_commands
17
+ from aiscript.runner import run_script
18
+
19
+ _HELP = """\
20
+ usage: aiscript NAME [args...] run a command (created/built on first use)
21
+ aiscript --init create config dir and config.yaml template
22
+ aiscript --list list commands
23
+ aiscript --edit NAME edit a command's prompt (rebuilds on next run)
24
+ aiscript --rebuild NAME regenerate a command's script (does not run it)
25
+ aiscript --remove NAME delete a command's prompt and script
26
+ aiscript --help | --version
27
+
28
+ Everything after NAME is passed untouched to the generated script.
29
+ """
30
+
31
+
32
+ def _build_parser() -> argparse.ArgumentParser:
33
+ parser = argparse.ArgumentParser(prog="aiscript", add_help=False)
34
+ group = parser.add_mutually_exclusive_group(required=True)
35
+ group.add_argument("--init", action="store_true")
36
+ group.add_argument("--list", action="store_true")
37
+ group.add_argument("--edit", metavar="NAME")
38
+ group.add_argument("--rebuild", metavar="NAME")
39
+ group.add_argument("--remove", metavar="NAME")
40
+ group.add_argument("--help", "-h", action="store_true")
41
+ group.add_argument("--version", action="store_true")
42
+ return parser
43
+
44
+
45
+ def _err(msg: str) -> None:
46
+ print(msg, file=sys.stderr)
47
+
48
+
49
+ def _editor_command() -> list[str]:
50
+ for var in ("VISUAL", "EDITOR"):
51
+ value = os.environ.get(var, "").strip()
52
+ if value:
53
+ return shlex.split(value, posix=not paths.is_windows())
54
+ if paths.is_windows():
55
+ return ["notepad"]
56
+ for candidate in ("nano", "vi"):
57
+ if shutil.which(candidate):
58
+ return [candidate]
59
+ raise AiscriptError("No editor found; set $VISUAL or $EDITOR.")
60
+
61
+
62
+ def _edit(name: str) -> int:
63
+ paths.validate_command_name(name)
64
+ ppath = paths.prompt_path(name)
65
+ if not ppath.exists():
66
+ raise AiscriptError(f"No prompt for {name!r}. Run `aiscript {name}` to create it.")
67
+ before = ppath.stat().st_mtime_ns
68
+ cmd = [*_editor_command(), str(ppath)]
69
+ try:
70
+ proc = subprocess.run(cmd)
71
+ except OSError as exc:
72
+ raise AiscriptError(f"Could not launch editor {cmd[0]!r}: {exc}") from exc
73
+ if proc.returncode != 0:
74
+ _err(f"aiscript: editor exited with status {proc.returncode}")
75
+ if ppath.stat().st_mtime_ns != before:
76
+ _err(f"Prompt for {name!r} changed; it will be rebuilt on next run.")
77
+ else:
78
+ _err(f"Prompt for {name!r} unchanged.")
79
+ return 0
80
+
81
+
82
+ def _rebuild(name: str) -> int:
83
+ paths.validate_command_name(name)
84
+ paths.ensure_dirs()
85
+ if not paths.prompt_path(name).exists():
86
+ raise AiscriptError(f"No prompt for {name!r}. Run `aiscript {name}` to create it.")
87
+ # build() replaces the cached script atomically, so a failed or rejected
88
+ # rebuild keeps the previous version.
89
+ print(builder.build(name, load_settings()))
90
+ return 0
91
+
92
+
93
+ def _remove(name: str) -> int:
94
+ paths.validate_command_name(name)
95
+ targets = [p for p in (paths.prompt_path(name), paths.function_path(name)) if p.exists()]
96
+ if not targets:
97
+ raise AiscriptError(f"No command named {name!r}.")
98
+ for path in targets:
99
+ try:
100
+ path.unlink()
101
+ except OSError as exc:
102
+ raise AiscriptError(f"Could not remove {path}: {exc}") from exc
103
+ _err(f"Removed {path}")
104
+ return 0
105
+
106
+
107
+ def _run_command(name: str, args: list[str]) -> int:
108
+ paths.validate_command_name(name)
109
+ paths.ensure_dirs()
110
+ ppath = paths.prompt_path(name)
111
+ if not ppath.exists():
112
+ sys.stderr.write(f"Command not found. What should '{name}' do? ")
113
+ sys.stderr.flush()
114
+ try:
115
+ answer = input("").strip()
116
+ except EOFError:
117
+ answer = ""
118
+ if not answer:
119
+ _err("")
120
+ raise AiscriptError("No description given; nothing saved.")
121
+ ppath.write_text(answer + "\n", encoding="utf-8")
122
+ if builder.needs_build(name):
123
+ builder.build(name, load_settings())
124
+ return run_script(paths.function_path(name), args)
125
+
126
+
127
+ def _dispatch(argv: list[str]) -> int:
128
+ if not argv:
129
+ sys.stderr.write(_HELP)
130
+ return 2
131
+ if argv[0].startswith("-"):
132
+ try:
133
+ ns = _build_parser().parse_args(argv)
134
+ except SystemExit:
135
+ return 2
136
+ if ns.help:
137
+ sys.stderr.write(_HELP)
138
+ return 0
139
+ if ns.version:
140
+ print(f"aiscript {__version__}")
141
+ return 0
142
+ if ns.init:
143
+ return run_init()
144
+ if ns.list:
145
+ return list_commands()
146
+ if ns.edit is not None:
147
+ return _edit(ns.edit)
148
+ if ns.remove is not None:
149
+ return _remove(ns.remove)
150
+ return _rebuild(ns.rebuild)
151
+ return _run_command(argv[0], argv[1:])
152
+
153
+
154
+ def main(argv: list[str] | None = None) -> int:
155
+ args = list(sys.argv[1:] if argv is None else argv)
156
+ try:
157
+ return _dispatch(args)
158
+ except AiscriptError as exc:
159
+ _err(f"aiscript: error: {exc}")
160
+ return exc.exit_code
161
+ except KeyboardInterrupt:
162
+ _err("")
163
+ return 130