aiscript 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aiscript/__init__.py +3 -0
- aiscript/__main__.py +8 -0
- aiscript/builder.py +66 -0
- aiscript/cli.py +163 -0
- aiscript/config.py +268 -0
- aiscript/errors.py +11 -0
- aiscript/init_cmd.py +108 -0
- aiscript/listing.py +104 -0
- aiscript/llm.py +259 -0
- aiscript/paths.py +77 -0
- aiscript/reviewer.py +491 -0
- aiscript/runner.py +43 -0
- aiscript-1.0.0.dist-info/METADATA +156 -0
- aiscript-1.0.0.dist-info/RECORD +17 -0
- aiscript-1.0.0.dist-info/WHEEL +4 -0
- aiscript-1.0.0.dist-info/entry_points.txt +3 -0
- aiscript-1.0.0.dist-info/licenses/LICENSE +21 -0
aiscript/__init__.py
ADDED
aiscript/__main__.py
ADDED
aiscript/builder.py
ADDED
|
@@ -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
|
aiscript/cli.py
ADDED
|
@@ -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
|
aiscript/config.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""config.yaml + environment loader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from aiscript.errors import ConfigError
|
|
14
|
+
from aiscript.paths import config_path, is_windows, legacy_secrets_path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ProviderConfig:
|
|
19
|
+
name: str
|
|
20
|
+
api_key: str | None
|
|
21
|
+
base_url: str | None
|
|
22
|
+
model: str
|
|
23
|
+
kind: str = "openai" # "openai" (OpenAI-compatible) or "jev" (TypeSafe System One; Ollaya too)
|
|
24
|
+
api: str = "chat" # for kind "openai": "responses" or "chat" (completions)
|
|
25
|
+
reasoning_effort: str | None = None # sent only when set
|
|
26
|
+
temperature: float | None = None # sent only when set (GPT-6 rejects it unless effort=none)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Settings:
|
|
31
|
+
generator: ProviderConfig
|
|
32
|
+
reviewer: ProviderConfig | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DEFAULT_PROVIDER = "openai"
|
|
36
|
+
DEFAULT_MODEL = "gpt-6-luna"
|
|
37
|
+
JEV_DEFAULT_MODEL = "jev-latest"
|
|
38
|
+
OLLAYA_DEFAULT_MODEL = "jevk5:4b"
|
|
39
|
+
OLLAYA_DEFAULT_URL = "http://localhost:11435"
|
|
40
|
+
_KINDS = {"openai", "jev", "ollaya"}
|
|
41
|
+
_APIS = {"responses", "chat"}
|
|
42
|
+
_OPENAI_HOSTS = {"api.openai.com"}
|
|
43
|
+
_JEV_NAMES = {"jev", "typesafe"}
|
|
44
|
+
_OLLAYA_NAMES = {"ollaya"}
|
|
45
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_raw_config() -> dict[str, Any]:
|
|
49
|
+
"""Return parsed config.yaml ({} if missing); ConfigError on invalid YAML.
|
|
50
|
+
|
|
51
|
+
Falls back to the legacy secrets.yaml (with a one-time stderr warning) when config.yaml
|
|
52
|
+
does not exist. The old file is never moved automatically.
|
|
53
|
+
"""
|
|
54
|
+
path = config_path()
|
|
55
|
+
if not path.is_file():
|
|
56
|
+
legacy = legacy_secrets_path()
|
|
57
|
+
if not legacy.is_file():
|
|
58
|
+
return {}
|
|
59
|
+
print(
|
|
60
|
+
f"aiscript: warning: secrets.yaml is deprecated; rename it: mv {legacy} {path}",
|
|
61
|
+
file=sys.stderr,
|
|
62
|
+
)
|
|
63
|
+
path = legacy
|
|
64
|
+
if not is_windows():
|
|
65
|
+
try:
|
|
66
|
+
mode = path.stat().st_mode
|
|
67
|
+
except OSError:
|
|
68
|
+
mode = 0
|
|
69
|
+
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
70
|
+
print(
|
|
71
|
+
f"aiscript: warning: {path} is accessible by other users; run: chmod 600 {path}",
|
|
72
|
+
file=sys.stderr,
|
|
73
|
+
)
|
|
74
|
+
import yaml # deferred: cached runs never read the config
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
78
|
+
except yaml.YAMLError as exc:
|
|
79
|
+
raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc
|
|
80
|
+
except OSError as exc:
|
|
81
|
+
raise ConfigError(f"Cannot read {path}: {exc}") from exc
|
|
82
|
+
if data is None:
|
|
83
|
+
return {}
|
|
84
|
+
if not isinstance(data, dict):
|
|
85
|
+
raise ConfigError(f"{path} must contain a YAML mapping at the top level.")
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _clean(value: object) -> str | None:
|
|
90
|
+
"""Return a stripped non-empty string, or None."""
|
|
91
|
+
if value is None:
|
|
92
|
+
return None
|
|
93
|
+
text = str(value).strip()
|
|
94
|
+
return text or None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_local(base_url: str | None) -> bool:
|
|
98
|
+
if not base_url:
|
|
99
|
+
return False
|
|
100
|
+
try:
|
|
101
|
+
host = urlparse(base_url).hostname
|
|
102
|
+
except ValueError:
|
|
103
|
+
return False
|
|
104
|
+
return (host or "").lower() in _LOCAL_HOSTS
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_official_openai(base_url: str | None) -> bool:
|
|
108
|
+
if not base_url:
|
|
109
|
+
return True
|
|
110
|
+
try:
|
|
111
|
+
return (urlparse(base_url).hostname or "").lower() in _OPENAI_HOSTS
|
|
112
|
+
except ValueError:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _env_key_name(provider: str) -> str:
|
|
117
|
+
return re.sub(r"[^A-Za-z0-9]", "_", provider).upper() + "_API_KEY"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _ollaya_base_url() -> str:
|
|
121
|
+
"""$OLLAYA_HOST (``host:port`` or a URL) or the local default."""
|
|
122
|
+
host = _clean(os.environ.get("OLLAYA_HOST"))
|
|
123
|
+
if host is None:
|
|
124
|
+
return OLLAYA_DEFAULT_URL
|
|
125
|
+
return host if "://" in host else f"http://{host}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve_provider(
|
|
129
|
+
name: str, raw: dict[str, Any], *, generator: bool, fallback_model: str | None = None
|
|
130
|
+
) -> ProviderConfig:
|
|
131
|
+
path = config_path()
|
|
132
|
+
providers = raw.get("providers")
|
|
133
|
+
if providers is None:
|
|
134
|
+
providers = {}
|
|
135
|
+
if not isinstance(providers, dict):
|
|
136
|
+
raise ConfigError(f"'providers' in {path} must be a mapping.")
|
|
137
|
+
if providers and name not in providers:
|
|
138
|
+
known = ", ".join(sorted(str(k) for k in providers))
|
|
139
|
+
raise ConfigError(
|
|
140
|
+
f"Provider '{name}' is not defined in {path} (known providers: {known}). "
|
|
141
|
+
"Edit the file or run `aiscript --init`."
|
|
142
|
+
)
|
|
143
|
+
entry = providers.get(name) or {}
|
|
144
|
+
if not isinstance(entry, dict):
|
|
145
|
+
raise ConfigError(f"Provider '{name}' in {path} must be a mapping.")
|
|
146
|
+
|
|
147
|
+
default_kind = (
|
|
148
|
+
"jev"
|
|
149
|
+
if name.lower() in _JEV_NAMES
|
|
150
|
+
else "ollaya"
|
|
151
|
+
if name.lower() in _OLLAYA_NAMES
|
|
152
|
+
else "openai"
|
|
153
|
+
)
|
|
154
|
+
kind = (_clean(entry.get("kind")) or default_kind).lower()
|
|
155
|
+
if kind not in _KINDS:
|
|
156
|
+
raise ConfigError(
|
|
157
|
+
f"Provider '{name}' in {path} has unknown kind '{kind}' "
|
|
158
|
+
"(expected: openai, jev, ollaya)."
|
|
159
|
+
)
|
|
160
|
+
# Ollaya speaks the Jev wire protocol: only its defaults differ, so it becomes kind "jev".
|
|
161
|
+
ollaya = kind == "ollaya"
|
|
162
|
+
if ollaya:
|
|
163
|
+
kind = "jev"
|
|
164
|
+
|
|
165
|
+
base_url = _clean(entry.get("base_url")) or _clean(entry.get("endpoint"))
|
|
166
|
+
if base_url is None and ollaya:
|
|
167
|
+
base_url = _ollaya_base_url()
|
|
168
|
+
if base_url is None and generator:
|
|
169
|
+
base_url = _clean(os.environ.get("OPENAI_BASE_URL"))
|
|
170
|
+
|
|
171
|
+
api_key = _clean(entry.get("api_key"))
|
|
172
|
+
if api_key is not None and api_key.endswith("..."):
|
|
173
|
+
api_key = None # placeholder from the template
|
|
174
|
+
if api_key is None:
|
|
175
|
+
api_key = _clean(os.environ.get(_env_key_name(name)))
|
|
176
|
+
if api_key is None and ollaya:
|
|
177
|
+
api_key = _clean(os.environ.get("OLLAYA_API_KEY")) or "not-needed" # auth is optional
|
|
178
|
+
if api_key is None and kind == "jev":
|
|
179
|
+
api_key = _clean(os.environ.get("TYPESAFE_API_KEY"))
|
|
180
|
+
# A local server never gets the global OpenAI key: it would be sent to it in the clear.
|
|
181
|
+
if api_key is None and generator and kind == "openai" and not is_local(base_url):
|
|
182
|
+
api_key = _clean(os.environ.get("OPENAI_API_KEY"))
|
|
183
|
+
if api_key is None and is_local(base_url):
|
|
184
|
+
api_key = "not-needed"
|
|
185
|
+
if api_key is None:
|
|
186
|
+
if not generator:
|
|
187
|
+
hint = f" (or comment out `reviewer_provider: {name}` to use only the static review)"
|
|
188
|
+
else:
|
|
189
|
+
hint = ""
|
|
190
|
+
env_names = _env_key_name(name)
|
|
191
|
+
if kind == "jev":
|
|
192
|
+
env_names += " or TYPESAFE_API_KEY"
|
|
193
|
+
elif generator:
|
|
194
|
+
env_names += " or OPENAI_API_KEY"
|
|
195
|
+
raise ConfigError(
|
|
196
|
+
f"No API key for provider '{name}'. Set `api_key` for it in {path} "
|
|
197
|
+
"(run `aiscript --init` to create the file, or edit "
|
|
198
|
+
f"~/.config/aiscript/config.yaml), or export {env_names}{hint}."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if base_url and base_url.lower().startswith("http://") and not is_local(base_url):
|
|
202
|
+
print(
|
|
203
|
+
f"aiscript: warning: provider '{name}' uses plain http:// ({base_url}); "
|
|
204
|
+
"its API key and your scripts travel unencrypted.",
|
|
205
|
+
file=sys.stderr,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
model = _clean(entry.get("model"))
|
|
209
|
+
if model is None and generator:
|
|
210
|
+
model = _clean(os.environ.get("AISCRIPT_MODEL"))
|
|
211
|
+
if model is None and ollaya:
|
|
212
|
+
model = OLLAYA_DEFAULT_MODEL
|
|
213
|
+
if model is None and kind == "jev":
|
|
214
|
+
model = JEV_DEFAULT_MODEL
|
|
215
|
+
if model is None:
|
|
216
|
+
model = fallback_model or DEFAULT_MODEL
|
|
217
|
+
|
|
218
|
+
# OpenAI's own endpoint gets the Responses API; other OpenAI-compatible servers
|
|
219
|
+
# (Ollama, vLLM, ...) often only implement Chat Completions.
|
|
220
|
+
api = (_clean(entry.get("api")) or "").lower() or (
|
|
221
|
+
"responses" if _is_official_openai(base_url) else "chat"
|
|
222
|
+
)
|
|
223
|
+
if api not in _APIS:
|
|
224
|
+
raise ConfigError(
|
|
225
|
+
f"Provider '{name}' in {path} has unknown api '{api}' (expected: responses, chat)."
|
|
226
|
+
)
|
|
227
|
+
reasoning_effort = _clean(entry.get("reasoning_effort"))
|
|
228
|
+
temperature = entry.get("temperature")
|
|
229
|
+
if temperature is not None:
|
|
230
|
+
try:
|
|
231
|
+
temperature = float(temperature)
|
|
232
|
+
except (TypeError, ValueError):
|
|
233
|
+
raise ConfigError(
|
|
234
|
+
f"Provider '{name}' in {path}: temperature must be a number."
|
|
235
|
+
) from None
|
|
236
|
+
return ProviderConfig(
|
|
237
|
+
name=name,
|
|
238
|
+
api_key=api_key,
|
|
239
|
+
base_url=base_url,
|
|
240
|
+
model=model,
|
|
241
|
+
kind=kind,
|
|
242
|
+
api=api,
|
|
243
|
+
reasoning_effort=reasoning_effort,
|
|
244
|
+
temperature=temperature,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def load_settings() -> Settings:
|
|
249
|
+
"""Resolve generator/reviewer providers; raise ConfigError with actionable text."""
|
|
250
|
+
raw = load_raw_config()
|
|
251
|
+
gen_name = (
|
|
252
|
+
_clean(os.environ.get("AISCRIPT_PROVIDER"))
|
|
253
|
+
or _clean(raw.get("default_provider"))
|
|
254
|
+
or DEFAULT_PROVIDER
|
|
255
|
+
)
|
|
256
|
+
generator = _resolve_provider(gen_name, raw, generator=True)
|
|
257
|
+
if generator.kind == "jev":
|
|
258
|
+
raise ConfigError(
|
|
259
|
+
f"Provider '{gen_name}' is a Jev-style (TypeSafe/Ollaya) classifier and cannot "
|
|
260
|
+
"generate scripts. "
|
|
261
|
+
"Use it as `reviewer_provider` and pick a chat model for `default_provider`."
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
reviewer = None
|
|
265
|
+
rev_name = _clean(raw.get("reviewer_provider"))
|
|
266
|
+
if rev_name:
|
|
267
|
+
reviewer = _resolve_provider(rev_name, raw, generator=False, fallback_model=generator.model)
|
|
268
|
+
return Settings(generator=generator, reviewer=reviewer)
|
aiscript/errors.py
ADDED
aiscript/init_cmd.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""`aiscript --init`: create config dirs, config.yaml, and .gitignore protection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from aiscript.paths import (
|
|
10
|
+
aiscript_home,
|
|
11
|
+
config_path,
|
|
12
|
+
ensure_dirs,
|
|
13
|
+
functions_dir,
|
|
14
|
+
is_windows,
|
|
15
|
+
legacy_secrets_path,
|
|
16
|
+
prompts_dir,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
CONFIG_TEMPLATE = """\
|
|
20
|
+
# aiscript config. Contains API keys: keep it private (chmod 600) and out of git.
|
|
21
|
+
# Keys can also come from the environment: OPENAI_API_KEY, TYPESAFE_API_KEY, <PROVIDER>_API_KEY.
|
|
22
|
+
|
|
23
|
+
# Provider that writes the scripts (override with $AISCRIPT_PROVIDER).
|
|
24
|
+
default_provider: openai
|
|
25
|
+
|
|
26
|
+
# Decision model that reviews scripts on top of the built-in static scan.
|
|
27
|
+
# Use `ollaya` for a local, keyless review; comment out to use only the static scan.
|
|
28
|
+
reviewer_provider: jev
|
|
29
|
+
|
|
30
|
+
providers:
|
|
31
|
+
# OpenAI (or any OpenAI-compatible API). API key: https://platform.openai.com/api-keys
|
|
32
|
+
openai:
|
|
33
|
+
api_key: "..."
|
|
34
|
+
model: "gpt-6-luna"
|
|
35
|
+
# reasoning_effort: low # none | low | medium | high | xhigh | max (model-dependent)
|
|
36
|
+
|
|
37
|
+
# Jev (TypeSafe AI) safety reviewer. API key: https://console.typesafe.ai/keys
|
|
38
|
+
jev:
|
|
39
|
+
api_key: "..."
|
|
40
|
+
|
|
41
|
+
# Uses Ollaya for a local decision model (https://ollaya.dev), model example: jevk5:4b
|
|
42
|
+
# ollaya:
|
|
43
|
+
# model: "jevk5:4b"
|
|
44
|
+
|
|
45
|
+
# Uses Ollama for a local LLM to create the scripts (set `default_provider: ollama`)
|
|
46
|
+
# ollama:
|
|
47
|
+
# base_url: "http://localhost:11434/v1"
|
|
48
|
+
# model: "qwen3"
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
# secrets.yaml stays listed so files from before the rename remain ignored.
|
|
52
|
+
_IGNORE_LINES = ("config.yaml", "secrets.yaml", "functions/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ensure_gitignore(home: Path) -> bool:
|
|
56
|
+
"""Append config.yaml, secrets.yaml, functions/ to home/.gitignore if home is a git repo."""
|
|
57
|
+
if not (home / ".git").exists():
|
|
58
|
+
return False
|
|
59
|
+
gi = home / ".gitignore"
|
|
60
|
+
existing = gi.read_text(encoding="utf-8") if gi.exists() else ""
|
|
61
|
+
present = {line.strip() for line in existing.splitlines()}
|
|
62
|
+
missing = [line for line in _IGNORE_LINES if line not in present]
|
|
63
|
+
if not missing:
|
|
64
|
+
return False
|
|
65
|
+
prefix = "" if not existing or existing.endswith("\n") else "\n"
|
|
66
|
+
gi.write_text(existing + prefix + "\n".join(missing) + "\n", encoding="utf-8")
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_init() -> int:
|
|
71
|
+
def say(msg: str) -> None:
|
|
72
|
+
print(msg, file=sys.stderr)
|
|
73
|
+
|
|
74
|
+
home = aiscript_home()
|
|
75
|
+
ensure_dirs()
|
|
76
|
+
say(f"Config directory: {home}")
|
|
77
|
+
say(f" prompts: {prompts_dir()}")
|
|
78
|
+
say(f" functions: {functions_dir()}")
|
|
79
|
+
|
|
80
|
+
path = config_path()
|
|
81
|
+
legacy = legacy_secrets_path()
|
|
82
|
+
if not path.exists() and legacy.is_file():
|
|
83
|
+
# Old layout: don't create a second file that would shadow the user's keys.
|
|
84
|
+
say(f"Found legacy {legacy}; rename it: mv {legacy} {path}")
|
|
85
|
+
path = legacy
|
|
86
|
+
else:
|
|
87
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
88
|
+
try:
|
|
89
|
+
fd = os.open(path, flags, 0o600)
|
|
90
|
+
except FileExistsError:
|
|
91
|
+
say(f"Config file already exists (left untouched): {path}")
|
|
92
|
+
else:
|
|
93
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
94
|
+
fh.write(CONFIG_TEMPLATE)
|
|
95
|
+
say(f"Created config file: {path}")
|
|
96
|
+
if not is_windows():
|
|
97
|
+
os.chmod(path, 0o600)
|
|
98
|
+
|
|
99
|
+
if ensure_gitignore(home):
|
|
100
|
+
say(f"Updated {home / '.gitignore'} (config.yaml, secrets.yaml, functions/)")
|
|
101
|
+
|
|
102
|
+
say("")
|
|
103
|
+
say("Next steps:")
|
|
104
|
+
say(f" 1. Put your OpenAI and Jev API keys in {path}")
|
|
105
|
+
say(" (or export OPENAI_API_KEY and TYPESAFE_API_KEY).")
|
|
106
|
+
say(" For a local, keyless review, use the `ollaya` provider (https://ollaya.dev).")
|
|
107
|
+
say(" 2. Create your first command: aiscript <name>")
|
|
108
|
+
return 0
|