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/listing.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Header parsing and ``--list`` output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from aiscript import paths
|
|
11
|
+
from aiscript.errors import AiscriptError
|
|
12
|
+
|
|
13
|
+
_HEADER_LINES = 40
|
|
14
|
+
_DESC_RE = re.compile(r"^\s*#\s*Description:\s*(.*?)\s*$", re.IGNORECASE)
|
|
15
|
+
_USAGE_RE = re.compile(r"^\s*#\s*Usage:\s*(.*?)\s*$", re.IGNORECASE)
|
|
16
|
+
_NOT_BUILT = "(not built)"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_header(path: Path) -> dict[str, str]:
|
|
20
|
+
"""Extract ``Description`` and ``Usage`` from a script's leading comment lines."""
|
|
21
|
+
result = {"description": "", "usage": ""}
|
|
22
|
+
try:
|
|
23
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
24
|
+
for _, line in zip(range(_HEADER_LINES), fh, strict=False):
|
|
25
|
+
if not result["description"]:
|
|
26
|
+
m = _DESC_RE.match(line)
|
|
27
|
+
if m:
|
|
28
|
+
result["description"] = m.group(1)
|
|
29
|
+
continue
|
|
30
|
+
if not result["usage"]:
|
|
31
|
+
m = _USAGE_RE.match(line)
|
|
32
|
+
if m:
|
|
33
|
+
result["usage"] = m.group(1)
|
|
34
|
+
except OSError:
|
|
35
|
+
pass
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _truncate(text: str, width: int) -> str:
|
|
40
|
+
if len(text) <= width:
|
|
41
|
+
return text
|
|
42
|
+
if width <= 1:
|
|
43
|
+
return text[:width]
|
|
44
|
+
return text[: width - 1] + "…"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _command_names() -> list[str]:
|
|
48
|
+
names: set[str] = set()
|
|
49
|
+
pdir, fdir = paths.prompts_dir(), paths.functions_dir()
|
|
50
|
+
if pdir.is_dir():
|
|
51
|
+
names.update(p.stem for p in pdir.glob("*.txt"))
|
|
52
|
+
if fdir.is_dir():
|
|
53
|
+
names.update(p.stem for p in fdir.glob(f"*.{paths.script_ext()}"))
|
|
54
|
+
valid = []
|
|
55
|
+
for n in names:
|
|
56
|
+
try:
|
|
57
|
+
paths.validate_command_name(n)
|
|
58
|
+
except AiscriptError:
|
|
59
|
+
continue
|
|
60
|
+
valid.append(n)
|
|
61
|
+
return sorted(valid, key=str.lower)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def list_commands() -> int:
|
|
65
|
+
"""Print a table of commands to stdout; return 0."""
|
|
66
|
+
rows: list[tuple[str, str, str]] = []
|
|
67
|
+
any_stale = False
|
|
68
|
+
for name in _command_names():
|
|
69
|
+
ppath = paths.prompt_path(name)
|
|
70
|
+
fpath = paths.function_path(name)
|
|
71
|
+
label = name
|
|
72
|
+
if fpath.exists():
|
|
73
|
+
header = parse_header(fpath)
|
|
74
|
+
desc, usage = header["description"], header["usage"]
|
|
75
|
+
if ppath.exists() and ppath.stat().st_mtime_ns > fpath.stat().st_mtime_ns:
|
|
76
|
+
label += " *"
|
|
77
|
+
any_stale = True
|
|
78
|
+
else:
|
|
79
|
+
desc, usage = _NOT_BUILT, ""
|
|
80
|
+
rows.append((label, desc, usage))
|
|
81
|
+
|
|
82
|
+
if not rows:
|
|
83
|
+
print("No commands yet. Try: aiscript <name>", file=sys.stderr)
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
cols = shutil.get_terminal_size(fallback=(100, 24)).columns
|
|
87
|
+
name_w = max(len("NAME"), *(len(r[0]) for r in rows))
|
|
88
|
+
usage_w = min(max(len("USAGE"), *(len(r[2]) for r in rows)), 40)
|
|
89
|
+
desc_w = max(20, cols - name_w - usage_w - 4)
|
|
90
|
+
desc_w = min(desc_w, max(len("DESCRIPTION"), *(len(r[1]) for r in rows)))
|
|
91
|
+
|
|
92
|
+
def fmt(a: str, b: str, c: str) -> str:
|
|
93
|
+
return f"{a:<{name_w}} {b:<{desc_w}} {c}".rstrip()
|
|
94
|
+
|
|
95
|
+
lines = [fmt("NAME", "DESCRIPTION", "USAGE")]
|
|
96
|
+
for label, desc, usage in rows:
|
|
97
|
+
lines.append(fmt(label, _truncate(desc, desc_w), _truncate(usage, usage_w)))
|
|
98
|
+
print("\n".join(lines))
|
|
99
|
+
if any_stale:
|
|
100
|
+
print(
|
|
101
|
+
"\n* prompt changed since last build; will rebuild on next run",
|
|
102
|
+
file=sys.stdout,
|
|
103
|
+
)
|
|
104
|
+
return 0
|
aiscript/llm.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""OpenAI-compatible client, system prompt, and script generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from aiscript import paths
|
|
11
|
+
from aiscript.errors import AiscriptError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import openai
|
|
15
|
+
|
|
16
|
+
from aiscript.config import ProviderConfig
|
|
17
|
+
|
|
18
|
+
_TIMEOUT_SECONDS = 120.0
|
|
19
|
+
_BASH_SHEBANG = "#!/usr/bin/env bash"
|
|
20
|
+
_FENCE_RE = re.compile(r"```[^\n`]*\n(.*?)```", re.DOTALL)
|
|
21
|
+
_OPEN_FENCE_RE = re.compile(r"```[^\n`]*\n(.*)\Z", re.DOTALL)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def make_client(p: ProviderConfig) -> openai.OpenAI:
|
|
25
|
+
"""Build an OpenAI client; ``base_url`` is passed only when configured."""
|
|
26
|
+
import openai
|
|
27
|
+
|
|
28
|
+
kwargs: dict[str, Any] = {"api_key": p.api_key or "not-needed", "timeout": _TIMEOUT_SECONDS}
|
|
29
|
+
if p.base_url is not None:
|
|
30
|
+
kwargs["base_url"] = p.base_url
|
|
31
|
+
return openai.OpenAI(**kwargs)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _where(p: ProviderConfig) -> str:
|
|
35
|
+
return f"provider '{p.name}' (model '{p.model}', base_url {p.base_url or 'default'})"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _request(client: openai.OpenAI, p: ProviderConfig, system: str, user: str) -> str:
|
|
39
|
+
"""Send one request with the provider's API flavour and return the text."""
|
|
40
|
+
if p.api == "responses":
|
|
41
|
+
kwargs: dict[str, Any] = {
|
|
42
|
+
"model": p.model,
|
|
43
|
+
"instructions": system,
|
|
44
|
+
"input": user,
|
|
45
|
+
"store": False,
|
|
46
|
+
}
|
|
47
|
+
if p.reasoning_effort:
|
|
48
|
+
kwargs["reasoning"] = {"effort": p.reasoning_effort}
|
|
49
|
+
if p.temperature is not None:
|
|
50
|
+
kwargs["temperature"] = p.temperature
|
|
51
|
+
response = client.responses.create(**kwargs)
|
|
52
|
+
if getattr(response, "status", None) == "incomplete":
|
|
53
|
+
details = getattr(response, "incomplete_details", None)
|
|
54
|
+
reason = getattr(details, "reason", None) or "unknown reason"
|
|
55
|
+
raise AiscriptError(f"{_where(p)} returned an incomplete response ({reason}).")
|
|
56
|
+
return getattr(response, "output_text", None) or ""
|
|
57
|
+
|
|
58
|
+
kwargs = {
|
|
59
|
+
"model": p.model,
|
|
60
|
+
"messages": [
|
|
61
|
+
{"role": "system", "content": system},
|
|
62
|
+
{"role": "user", "content": user},
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
if p.reasoning_effort:
|
|
66
|
+
kwargs["reasoning_effort"] = p.reasoning_effort
|
|
67
|
+
if p.temperature is not None:
|
|
68
|
+
kwargs["temperature"] = p.temperature
|
|
69
|
+
response = client.chat.completions.create(**kwargs)
|
|
70
|
+
choices = getattr(response, "choices", None)
|
|
71
|
+
return (choices[0].message.content if choices else None) or ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def chat(p: ProviderConfig, system: str, user: str) -> str:
|
|
75
|
+
"""Run one system+user exchange and return the model's text.
|
|
76
|
+
|
|
77
|
+
``temperature`` and ``reasoning_effort`` are sent only when set on the provider:
|
|
78
|
+
GPT-6 models reject ``temperature`` unless reasoning effort is ``none``.
|
|
79
|
+
"""
|
|
80
|
+
import openai
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
content = _request(make_client(p), p, system, user)
|
|
84
|
+
except openai.AuthenticationError as e:
|
|
85
|
+
raise AiscriptError(
|
|
86
|
+
f"Authentication failed for {_where(p)}: check the API key in config.yaml. ({e})"
|
|
87
|
+
) from e
|
|
88
|
+
except openai.APITimeoutError as e:
|
|
89
|
+
raise AiscriptError(
|
|
90
|
+
f"Request to {_where(p)} timed out after {_TIMEOUT_SECONDS:.0f}s."
|
|
91
|
+
) from e
|
|
92
|
+
except openai.APIConnectionError as e:
|
|
93
|
+
raise AiscriptError(
|
|
94
|
+
f"Could not connect to {_where(p)}: check the endpoint URL and your network. ({e})"
|
|
95
|
+
) from e
|
|
96
|
+
except openai.APIStatusError as e:
|
|
97
|
+
raise AiscriptError(f"{_where(p)} returned HTTP {e.status_code}: {e.message}") from e
|
|
98
|
+
except openai.OpenAIError as e:
|
|
99
|
+
raise AiscriptError(f"Error calling {_where(p)}: {e}") from e
|
|
100
|
+
|
|
101
|
+
if not content.strip():
|
|
102
|
+
raise AiscriptError(f"{_where(p)} returned an empty response.")
|
|
103
|
+
return content
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_COMMON_RULES = """\
|
|
107
|
+
You are a script generator embedded in a command-line tool called aiscript. The user gives you a \
|
|
108
|
+
command name and a plain-English description of what the command should do. You write ONE \
|
|
109
|
+
complete, working {shell_name} script that implements it.
|
|
110
|
+
|
|
111
|
+
TARGET ENVIRONMENT
|
|
112
|
+
- Shell: {shell_name}
|
|
113
|
+
- Host OS: {os_name}
|
|
114
|
+
Use only commands and flags that exist on this OS. Prefer tools that ship with the OS; if you \
|
|
115
|
+
must rely on an optional tool, check for it with {check_cmd} and print a clear error that tells \
|
|
116
|
+
the user how to install it.
|
|
117
|
+
|
|
118
|
+
OUTPUT FORMAT (critical)
|
|
119
|
+
- Output ONLY the raw script source code.
|
|
120
|
+
- Do NOT wrap it in markdown fences (no ```), and write no explanations, greetings or prose \
|
|
121
|
+
before or after the code. Anything outside the script breaks the tool.
|
|
122
|
+
|
|
123
|
+
ARGUMENTS
|
|
124
|
+
- The script receives the user's arguments positionally: {args_doc}.
|
|
125
|
+
- Decide which arguments the intent needs. Validate required arguments up front; if any are \
|
|
126
|
+
missing or invalid, print a short usage message to stderr (e.g. "Usage: <name> <args...>") and \
|
|
127
|
+
exit with a non-zero status.
|
|
128
|
+
- If the intent needs no arguments, do not demand any.
|
|
129
|
+
- Never prompt interactively unless the intent explicitly calls for it.
|
|
130
|
+
|
|
131
|
+
SAFETY DEFAULTS
|
|
132
|
+
- Prefer non-destructive actions. {delete_doc}
|
|
133
|
+
- Never use {elevation} unless the intent explicitly requires it. Operate within the user's home \
|
|
134
|
+
directory or the current working directory whenever possible.
|
|
135
|
+
- Do not change network, firewall, DNS or system settings unless the intent explicitly asks.
|
|
136
|
+
- Do not download and execute remote code.
|
|
137
|
+
- For bulk or irreversible changes, show what will happen and ask for confirmation, or support a \
|
|
138
|
+
dry-run mode, unless the intent clearly says to proceed without asking.
|
|
139
|
+
- {quote_doc}
|
|
140
|
+
- Fail loudly: stop on errors and print helpful messages to stderr.
|
|
141
|
+
|
|
142
|
+
SCRIPT HEADER (critical, parsed by `aiscript --list`)
|
|
143
|
+
{header_doc}
|
|
144
|
+
Keep "Description" and "Usage" each on a single line, using exactly those labels. Replace \
|
|
145
|
+
<name> with the command name given by the user.
|
|
146
|
+
|
|
147
|
+
{shell_specific}
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
_BASH_SPECIFIC = """\
|
|
151
|
+
BASH SPECIFICS
|
|
152
|
+
- The very first line must be `#!/usr/bin/env bash`, followed by the header comment lines, and \
|
|
153
|
+
then `set -euo pipefail`.
|
|
154
|
+
- Use `"$1"`, `"$2"`, `"$@"`, `$#` for arguments; use `"${1:-}"` when testing an optional \
|
|
155
|
+
argument under `set -u`.
|
|
156
|
+
- Write portable, readable Bash; avoid unnecessary cleverness. Use `[[ ... ]]` for tests and \
|
|
157
|
+
`local` for function variables.
|
|
158
|
+
- Use `mktemp` for temporary files and clean up with a `trap`."""
|
|
159
|
+
|
|
160
|
+
_POWERSHELL_SPECIFIC = """\
|
|
161
|
+
POWERSHELL SPECIFICS
|
|
162
|
+
- Do NOT add a shebang. Start directly with the header comment lines (using `#` comments only, \
|
|
163
|
+
never `<# #>` blocks for the header), then `$ErrorActionPreference = 'Stop'`.
|
|
164
|
+
- Read arguments with `$args[0]`, `$args[1]`, `$args.Count`; do not declare a `param()` block \
|
|
165
|
+
before the header.
|
|
166
|
+
- Write to the error stream with `Write-Error` or `[Console]::Error.WriteLine(...)` and use \
|
|
167
|
+
`exit 1` for failures.
|
|
168
|
+
- Use `-LiteralPath` for paths that come from arguments."""
|
|
169
|
+
|
|
170
|
+
_BASH_PARTS = {
|
|
171
|
+
"shell_name": "Bash",
|
|
172
|
+
"check_cmd": "`command -v`",
|
|
173
|
+
"args_doc": "`$1`, `$2`, ... and `$@` / `$#`",
|
|
174
|
+
"elevation": "`sudo`, `doas` or any privilege escalation",
|
|
175
|
+
"delete_doc": (
|
|
176
|
+
"Never use recursive forced deletion (`rm -r`, `rm -rf`). To delete things, move them to "
|
|
177
|
+
"the trash instead: use `trash` (`trash-put` from trash-cli) if available, otherwise "
|
|
178
|
+
"move the items into `~/.Trash` (create it if needed, avoiding name collisions), and "
|
|
179
|
+
"tell the user where they went."
|
|
180
|
+
),
|
|
181
|
+
"quote_doc": (
|
|
182
|
+
'Quote every variable expansion (`"$var"`, `"${arr[@]}"`) and handle spaces in names.'
|
|
183
|
+
),
|
|
184
|
+
"header_doc": (
|
|
185
|
+
"Immediately after the shebang line, write exactly this comment block:\n"
|
|
186
|
+
"#!/usr/bin/env bash\n"
|
|
187
|
+
"# aiscript: <name>\n"
|
|
188
|
+
"# Description: <one line summary>\n"
|
|
189
|
+
"# Usage: <name> <args...>\n"
|
|
190
|
+
"# <optional further comment lines explaining the behaviour>"
|
|
191
|
+
),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
_POWERSHELL_PARTS = {
|
|
195
|
+
"shell_name": "PowerShell",
|
|
196
|
+
"check_cmd": "`Get-Command`",
|
|
197
|
+
"args_doc": "`$args[0]`, `$args[1]`, ... and `$args.Count`",
|
|
198
|
+
"elevation": "Run-as-administrator, `Start-Process -Verb RunAs` or any elevation",
|
|
199
|
+
"delete_doc": (
|
|
200
|
+
"Never permanently delete with `Remove-Item -Recurse -Force`. Send items to the Recycle "
|
|
201
|
+
"Bin instead (for example `Add-Type -AssemblyName Microsoft.VisualBasic` and then "
|
|
202
|
+
"`[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($path, 'OnlyErrorDialogs', "
|
|
203
|
+
"'SendToRecycleBin')`), and tell the user what was moved."
|
|
204
|
+
),
|
|
205
|
+
"quote_doc": "Quote paths and strings properly and handle spaces in names.",
|
|
206
|
+
"header_doc": (
|
|
207
|
+
"The very first lines of the script must be exactly this comment block (no shebang):\n"
|
|
208
|
+
"# aiscript: <name>\n"
|
|
209
|
+
"# Description: <one line summary>\n"
|
|
210
|
+
"# Usage: <name> <args...>\n"
|
|
211
|
+
"# <optional further comment lines explaining the behaviour>"
|
|
212
|
+
),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def system_prompt(shell: str, os_name: str) -> str:
|
|
217
|
+
"""Build the system prompt for ``shell`` ('bash' or 'powershell') on ``os_name``."""
|
|
218
|
+
if shell == "powershell":
|
|
219
|
+
parts, specific = _POWERSHELL_PARTS, _POWERSHELL_SPECIFIC
|
|
220
|
+
else:
|
|
221
|
+
parts, specific = _BASH_PARTS, _BASH_SPECIFIC
|
|
222
|
+
return _COMMON_RULES.format(os_name=os_name, shell_specific=specific, **parts).strip() + "\n"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def clean_script(raw: str, shell: str) -> str:
|
|
226
|
+
"""Extract script code from model output and normalise it."""
|
|
227
|
+
text = raw or ""
|
|
228
|
+
match = _FENCE_RE.search(text) or _OPEN_FENCE_RE.search(text)
|
|
229
|
+
if match:
|
|
230
|
+
text = match.group(1)
|
|
231
|
+
text = text.strip()
|
|
232
|
+
if not text:
|
|
233
|
+
raise AiscriptError("The model returned an empty script.")
|
|
234
|
+
if shell != "powershell" and not text.startswith("#!"):
|
|
235
|
+
text = f"{_BASH_SHEBANG}\n{text}"
|
|
236
|
+
return text + "\n"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _os_description() -> str:
|
|
240
|
+
system = platform.system() or "Unknown"
|
|
241
|
+
desc = f"{system} {platform.release()}".strip()
|
|
242
|
+
if system == "Linux":
|
|
243
|
+
try:
|
|
244
|
+
for line in Path("/etc/os-release").read_text(encoding="utf-8").splitlines():
|
|
245
|
+
if line.startswith("PRETTY_NAME="):
|
|
246
|
+
pretty = line.split("=", 1)[1].strip().strip("\"'")
|
|
247
|
+
desc += f" ({pretty})"
|
|
248
|
+
break
|
|
249
|
+
except OSError:
|
|
250
|
+
pass
|
|
251
|
+
return desc
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def generate_script(p: ProviderConfig, name: str, intent: str) -> str:
|
|
255
|
+
"""Ask the model for a script implementing ``intent`` and return it cleaned."""
|
|
256
|
+
shell = paths.shell_kind()
|
|
257
|
+
system = system_prompt(shell, _os_description())
|
|
258
|
+
user = f"Command name: {name}\n\nWhat the command should do:\n{intent.strip()}"
|
|
259
|
+
return clean_script(chat(p, system, user), shell)
|
aiscript/paths.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Config-root paths, name validation, and OS/shell detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from aiscript.errors import AiscriptError
|
|
12
|
+
|
|
13
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def aiscript_home() -> Path:
|
|
17
|
+
"""Return the config root: $AISCRIPT_HOME, $XDG_CONFIG_HOME/aiscript, or ~/.config/aiscript."""
|
|
18
|
+
override = os.environ.get("AISCRIPT_HOME")
|
|
19
|
+
if override:
|
|
20
|
+
return Path(override).expanduser()
|
|
21
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
22
|
+
if xdg:
|
|
23
|
+
return Path(xdg).expanduser() / "aiscript"
|
|
24
|
+
return Path.home() / ".config" / "aiscript"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def prompts_dir() -> Path:
|
|
28
|
+
return aiscript_home() / "prompts"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def functions_dir() -> Path:
|
|
32
|
+
return aiscript_home() / "functions"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def config_path() -> Path:
|
|
36
|
+
return aiscript_home() / "config.yaml"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def legacy_secrets_path() -> Path:
|
|
40
|
+
"""Old name of config.yaml; still read (with a warning) when config.yaml is missing."""
|
|
41
|
+
return aiscript_home() / "secrets.yaml"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ensure_dirs() -> None:
|
|
45
|
+
"""Create prompts/ and functions/ (and the home) if missing."""
|
|
46
|
+
prompts_dir().mkdir(parents=True, exist_ok=True)
|
|
47
|
+
functions_dir().mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_command_name(name: str) -> str:
|
|
51
|
+
"""Return ``name`` if it is a safe command name, else raise AiscriptError."""
|
|
52
|
+
if not _NAME_RE.fullmatch(name):
|
|
53
|
+
raise AiscriptError(
|
|
54
|
+
f"Invalid command name {name!r}: use letters, digits, '_' or '-', "
|
|
55
|
+
"starting with a letter or digit."
|
|
56
|
+
)
|
|
57
|
+
return name
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_windows() -> bool:
|
|
61
|
+
return platform.system() == "Windows"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def shell_kind() -> Literal["bash", "powershell"]:
|
|
65
|
+
return "powershell" if is_windows() else "bash"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def script_ext() -> Literal["sh", "ps1"]:
|
|
69
|
+
return "ps1" if is_windows() else "sh"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def prompt_path(name: str) -> Path:
|
|
73
|
+
return prompts_dir() / f"{validate_command_name(name)}.txt"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def function_path(name: str) -> Path:
|
|
77
|
+
return functions_dir() / f"{validate_command_name(name)}.{script_ext()}"
|