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 +21 -0
- aiscript-1.0.0/PKG-INFO +156 -0
- aiscript-1.0.0/README.md +142 -0
- aiscript-1.0.0/pyproject.toml +56 -0
- aiscript-1.0.0/pyproject.toml.orig +49 -0
- aiscript-1.0.0/src/aiscript/__init__.py +3 -0
- aiscript-1.0.0/src/aiscript/__main__.py +8 -0
- aiscript-1.0.0/src/aiscript/builder.py +66 -0
- aiscript-1.0.0/src/aiscript/cli.py +163 -0
- aiscript-1.0.0/src/aiscript/config.py +268 -0
- aiscript-1.0.0/src/aiscript/errors.py +11 -0
- aiscript-1.0.0/src/aiscript/init_cmd.py +108 -0
- aiscript-1.0.0/src/aiscript/listing.py +104 -0
- aiscript-1.0.0/src/aiscript/llm.py +259 -0
- aiscript-1.0.0/src/aiscript/paths.py +77 -0
- aiscript-1.0.0/src/aiscript/reviewer.py +491 -0
- aiscript-1.0.0/src/aiscript/runner.py +43 -0
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.
|
aiscript-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/aiscript/)
|
|
18
|
+
[](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)
|
aiscript-1.0.0/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# aiscript
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/aiscript/)
|
|
4
|
+
[](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,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
|