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 ADDED
@@ -0,0 +1,3 @@
1
+ """aiscript: JIT-compile plain-English intents into cached shell scripts."""
2
+
3
+ __version__ = "1.0.0"
aiscript/__main__.py ADDED
@@ -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())
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
@@ -0,0 +1,11 @@
1
+ """Exception types."""
2
+
3
+
4
+ class AiscriptError(Exception):
5
+ """Base error; the CLI prints it and exits with ``exit_code``."""
6
+
7
+ exit_code: int = 1
8
+
9
+
10
+ class ConfigError(AiscriptError):
11
+ """Invalid or missing configuration."""
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