aether-rsi 0.1.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.
- aether/__init__.py +5 -0
- aether/__main__.py +272 -0
- aether/extensions.py +174 -0
- aether/fallback/agent/claude-code/README.md +109 -0
- aether/fallback/agent/claude-code/aether.json +9 -0
- aether/fallback/agent/claude-code/agent.py +148 -0
- aether/fallback/preview/text-preview/aether.json +9 -0
- aether/fallback/preview/text-preview/index.html +81 -0
- aether/files.py +284 -0
- aether/paste.py +108 -0
- aether/reload.py +99 -0
- aether/render.py +191 -0
- aether/runner.py +450 -0
- aether/sample_project/AGENTS.md +7 -0
- aether/sample_project/README.md +17 -0
- aether/sample_project/apps/hello-app/AGENTS.md +7 -0
- aether/sample_project/apps/hello-app/aether.json +8 -0
- aether/sample_project/apps/hello-app/data.json +6 -0
- aether/sample_project/apps/hello-app/index.html +42 -0
- aether/sample_project/extensions/agent/echo-agent/aether.json +13 -0
- aether/sample_project/extensions/agent/echo-agent/agent.py +86 -0
- aether/sample_project/extensions/preview/json-preview/aether.json +8 -0
- aether/sample_project/extensions/preview/json-preview/index.html +72 -0
- aether/sample_project/samples/NOEXTENSION +1 -0
- aether/sample_project/samples/README.md +14 -0
- aether/sample_project/samples/json-all.json +76 -0
- aether/sample_project/samples/json-broken.json +5 -0
- aether/sample_project/samples/json-large.json +36405 -0
- aether/sample_project/samples/text-binary.bin +0 -0
- aether/sample_project/samples/text-code.py +6 -0
- aether/sample_project/samples/text-crlf.txt +3 -0
- aether/sample_project/samples/text-empty.txt +0 -0
- aether/sample_project/samples/text-image.png +0 -0
- aether/sample_project/samples/text-latin1.txt +1 -0
- aether/sample_project/samples/text-long.log +20500 -0
- aether/sample_project/samples/text-table.csv +4 -0
- aether/server.py +901 -0
- aether/settings.py +103 -0
- aether/static/aether-sdk.js +183 -0
- aether/static/core.css +435 -0
- aether/static/core.js +1683 -0
- aether/static/icons.js +146 -0
- aether/static/index.html +95 -0
- aether/store.py +314 -0
- aether/voice.py +204 -0
- aether_rsi-0.1.0.dist-info/METADATA +58 -0
- aether_rsi-0.1.0.dist-info/RECORD +51 -0
- aether_rsi-0.1.0.dist-info/WHEEL +5 -0
- aether_rsi-0.1.0.dist-info/entry_points.txt +2 -0
- aether_rsi-0.1.0.dist-info/licenses/LICENSE +21 -0
- aether_rsi-0.1.0.dist-info/top_level.txt +1 -0
aether/__init__.py
ADDED
aether/__main__.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""python3 -m aether [--workspace DIR] [--port N] [--reload] [--no-init] | python3 -m aether check <extension folder> | python3 -m aether init [DIR]
|
|
2
|
+
| python3 -m aether trust [DIR] [--remove] | python3 -m aether setup vertex [--project ID] [--region R] [--key FILE | --adc]
|
|
3
|
+
(installed with pip, the command is `aether`). Without --workspace the core uses the folder saved from the page; a fresh install asks
|
|
4
|
+
in the terminal whether to make a SampleProject (else the page asks for a folder)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import errno
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import VERSION, extensions
|
|
17
|
+
from . import settings as settings_mod
|
|
18
|
+
from .server import REPO, RECENT, check_workspace, serve, source_checkout
|
|
19
|
+
|
|
20
|
+
SAMPLE = Path(__file__).resolve().parent / "sample_project" # shipped in the wheel: what `aether init` copies out
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_workspace() -> str:
|
|
24
|
+
"""AETHER_WORKSPACE, else "" ―― the folder saved in config.json, or (a fresh install) the shell asks for one."""
|
|
25
|
+
return os.environ.get("AETHER_WORKSPACE") or ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_data() -> str:
|
|
29
|
+
return os.environ.get("AETHER_DATA") or str(Path.home() / ".aether-rsi")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def saved_workspace(data) -> str:
|
|
33
|
+
"""config.json `workspace` ("" when none, or the file is unreadable)."""
|
|
34
|
+
try:
|
|
35
|
+
return json.loads((Path(data).expanduser() / "config.json").read_text()).get("workspace") or ""
|
|
36
|
+
except (OSError, ValueError, AttributeError):
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_workspace(data, ws: Path):
|
|
41
|
+
"""Keep ws as the workspace for the next start, the way the page's form does (workspace + recent)."""
|
|
42
|
+
d = Path(data).expanduser()
|
|
43
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
cfg = d / "config.json"
|
|
45
|
+
try:
|
|
46
|
+
conf = json.loads(cfg.read_text()) if cfg.is_file() else {}
|
|
47
|
+
except ValueError:
|
|
48
|
+
conf = {}
|
|
49
|
+
conf["recent"] = list(dict.fromkeys(p for p in [str(ws), conf.get("workspace") or "", *(conf.get("recent") or [])] if isinstance(p, str) and p))[:RECENT]
|
|
50
|
+
conf["workspace"] = str(ws)
|
|
51
|
+
tmp = cfg.with_suffix(".tmp")
|
|
52
|
+
tmp.write_text(json.dumps(conf, ensure_ascii=False, indent=2))
|
|
53
|
+
tmp.replace(cfg)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def set_trusted(data, folder: Path, on: bool = True) -> list:
|
|
57
|
+
"""config.json `trusted`: the folders whose own code the person trusts (their extensions/ may run commands; agents load their
|
|
58
|
+
settings, hooks and MCP servers). Returns the list as saved."""
|
|
59
|
+
d = Path(data).expanduser()
|
|
60
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
cfg = d / "config.json"
|
|
62
|
+
try:
|
|
63
|
+
conf = json.loads(cfg.read_text()) if cfg.is_file() else {}
|
|
64
|
+
except ValueError:
|
|
65
|
+
conf = {}
|
|
66
|
+
have = [t for t in (conf.get("trusted") or []) if isinstance(t, str) and t != str(folder)]
|
|
67
|
+
conf["trusted"] = [*have, str(folder)] if on else have
|
|
68
|
+
tmp = cfg.with_suffix(".tmp")
|
|
69
|
+
tmp.write_text(json.dumps(conf, ensure_ascii=False, indent=2))
|
|
70
|
+
tmp.replace(cfg)
|
|
71
|
+
return conf["trusted"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def trust(argv) -> int:
|
|
75
|
+
ap = argparse.ArgumentParser(prog="aether trust", description="Trust a folder's own code: its extensions/ may run commands and agents "
|
|
76
|
+
"load its settings, hooks and MCP servers. For the person: it refuses to run inside an agent's job")
|
|
77
|
+
ap.add_argument("dir", nargs="?", default=".")
|
|
78
|
+
ap.add_argument("--remove", action="store_true", help="stop trusting it")
|
|
79
|
+
ap.add_argument("--data", default=default_data())
|
|
80
|
+
a = ap.parse_args(argv)
|
|
81
|
+
if os.environ.get("AETHER_AGENT") == "1":
|
|
82
|
+
print("aether trust: refused inside an agent's job (only the person trusts a folder)", file=sys.stderr)
|
|
83
|
+
return 2
|
|
84
|
+
folder = Path(a.dir).expanduser().resolve()
|
|
85
|
+
if not folder.is_dir():
|
|
86
|
+
print(f"aether trust: no such folder: {folder}", file=sys.stderr)
|
|
87
|
+
return 2
|
|
88
|
+
now = set_trusted(a.data, folder, not a.remove)
|
|
89
|
+
print(f"{'no longer trusted' if a.remove else 'trusted'}: {folder} (trusted now: {len(now)})")
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
KEY_TYPES = {"service_account", "authorized_user", "external_account", "impersonated_service_account"}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def setup(argv) -> int:
|
|
97
|
+
"""`aether setup vertex`: how agents reach the models, written to settings.json from a terminal (the page only shows it).
|
|
98
|
+
No --key: the person's own sign-in (ADC: Cloud Shell, or `gcloud auth application-default login`), no key file anywhere."""
|
|
99
|
+
ap = argparse.ArgumentParser(prog="aether setup vertex", description="Set how agents reach Vertex AI (written to settings.json). "
|
|
100
|
+
"Without --key they use your own sign-in (ADC); refused inside an agent's job")
|
|
101
|
+
ap.add_argument("what", choices=["vertex"])
|
|
102
|
+
ap.add_argument("--project", help="the Google Cloud project that runs the models")
|
|
103
|
+
ap.add_argument("--region", help="the Vertex AI region (default: global)")
|
|
104
|
+
g = ap.add_mutually_exclusive_group()
|
|
105
|
+
g.add_argument("--key", help="a service-account key file, for a project your own sign-in cannot use")
|
|
106
|
+
g.add_argument("--adc", action="store_true", help="drop the key file: use your own sign-in")
|
|
107
|
+
ap.add_argument("--data", default=default_data())
|
|
108
|
+
a = ap.parse_args(argv)
|
|
109
|
+
if os.environ.get("AETHER_AGENT") == "1":
|
|
110
|
+
print("aether setup: refused inside an agent's job (only the person sets how agents reach the models)", file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
path = settings_mod.where(REPO, Path(a.data).expanduser(), source_checkout())
|
|
113
|
+
try:
|
|
114
|
+
raw = json.loads(path.read_text()) if path.is_file() else {}
|
|
115
|
+
except ValueError as e:
|
|
116
|
+
print(f"aether setup: {path} is not JSON ({e}); fix or remove it first", file=sys.stderr)
|
|
117
|
+
return 2
|
|
118
|
+
v = dict(raw.get("vertex") or {})
|
|
119
|
+
if not (a.project or a.region or a.key or a.adc): # no change asked: say what is set (never the key's contents)
|
|
120
|
+
how = f"key file {v['credentials']}" if v.get("credentials") else ("your own sign-in (ADC)" if v.get("project") else "not set")
|
|
121
|
+
print(f"settings: {path}\nvertex: project={v.get('project') or '-'} region={v.get('region') or '-'} sign-in={how}")
|
|
122
|
+
return 0
|
|
123
|
+
if a.key:
|
|
124
|
+
key = Path(a.key).expanduser().resolve()
|
|
125
|
+
try:
|
|
126
|
+
kind = json.loads(key.read_text()).get("type")
|
|
127
|
+
except (OSError, ValueError, AttributeError) as e:
|
|
128
|
+
print(f"aether setup: cannot use {key} as a key file ({type(e).__name__})", file=sys.stderr)
|
|
129
|
+
return 2
|
|
130
|
+
if kind not in KEY_TYPES:
|
|
131
|
+
print(f"aether setup: {key} is not a Google credentials file (type {kind!r})", file=sys.stderr)
|
|
132
|
+
return 2
|
|
133
|
+
v["credentials"] = str(key)
|
|
134
|
+
ws = saved_workspace(a.data)
|
|
135
|
+
if ws and (Path(ws).resolve() == key or Path(ws).resolve() in key.parents):
|
|
136
|
+
print(f"note: the key sits inside the workspace {ws}. The core never shows or serves it, but outside is safer", file=sys.stderr)
|
|
137
|
+
if a.adc:
|
|
138
|
+
v.pop("credentials", None)
|
|
139
|
+
if a.project:
|
|
140
|
+
v["project"] = a.project
|
|
141
|
+
v["region"] = a.region or v.get("region") or "global"
|
|
142
|
+
if not v.get("project"):
|
|
143
|
+
print("aether setup: --project is needed the first time", file=sys.stderr)
|
|
144
|
+
return 2
|
|
145
|
+
raw["vertex"] = v
|
|
146
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
tmp = path.with_suffix(".tmp")
|
|
148
|
+
tmp.write_text(json.dumps(raw, ensure_ascii=False, indent=2) + "\n")
|
|
149
|
+
tmp.replace(path)
|
|
150
|
+
print(f"saved {path}: project={v['project']} region={v['region']} sign-in={'key file' if v.get('credentials') else 'your own (ADC)'}"
|
|
151
|
+
" (a running aether reads it again by itself)")
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def make_project(where=None) -> Path:
|
|
156
|
+
"""Copy the SampleProject to where (default ./SampleProject). A taken name becomes name-2, name-3 …: nothing is replaced."""
|
|
157
|
+
if not SAMPLE.is_dir():
|
|
158
|
+
raise ValueError(f"this install has no sample project ({SAMPLE})")
|
|
159
|
+
want = Path(where or "SampleProject").expanduser().resolve()
|
|
160
|
+
dest, n = want, 1
|
|
161
|
+
while dest.exists():
|
|
162
|
+
n += 1
|
|
163
|
+
dest = want.with_name(f"{want.name}-{n}")
|
|
164
|
+
if not dest.parent.is_dir():
|
|
165
|
+
raise ValueError(f"no such folder: {dest.parent}")
|
|
166
|
+
shutil.copytree(SAMPLE, dest, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store"))
|
|
167
|
+
return check_workspace(str(dest)) # the copy is never home or a disk root, but the rule lives in one place
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def init(argv) -> int:
|
|
171
|
+
ap = argparse.ArgumentParser(prog="aether init", description="Make a SampleProject (apps/hello-app and sample extensions) without asking")
|
|
172
|
+
ap.add_argument("dir", nargs="?", default="SampleProject", help="where to make it (default ./SampleProject; a taken name gets -2, -3 …)")
|
|
173
|
+
ap.add_argument("--data", default=default_data())
|
|
174
|
+
a = ap.parse_args(argv)
|
|
175
|
+
try:
|
|
176
|
+
ws = make_project(a.dir)
|
|
177
|
+
except (ValueError, OSError) as e:
|
|
178
|
+
print(f"aether init: {e}", file=sys.stderr)
|
|
179
|
+
return 2
|
|
180
|
+
print(f"made {ws}")
|
|
181
|
+
set_trusted(a.data, ws) # the person asked for it and its code ships with the core
|
|
182
|
+
if saved_workspace(a.data):
|
|
183
|
+
print(f"start it with: aether --workspace {ws} (your saved workspace stays as it is)")
|
|
184
|
+
else:
|
|
185
|
+
save_workspace(a.data, ws)
|
|
186
|
+
print("saved as the workspace. start with: aether")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def ask_init(data) -> str:
|
|
191
|
+
"""A fresh install started from a terminal: make ./SampleProject? "" = no (the page asks for a folder, as before)."""
|
|
192
|
+
try:
|
|
193
|
+
ans = input("AetherRSI: no workspace yet. Make a sample project at ./SampleProject? [Y/n] ").strip().lower()
|
|
194
|
+
except EOFError:
|
|
195
|
+
return ""
|
|
196
|
+
if ans not in ("", "y", "yes"):
|
|
197
|
+
return ""
|
|
198
|
+
try:
|
|
199
|
+
ws = make_project()
|
|
200
|
+
except (ValueError, OSError) as e:
|
|
201
|
+
print(f"could not make it ({e}); enter a folder in the page instead", file=sys.stderr)
|
|
202
|
+
return ""
|
|
203
|
+
save_workspace(data, ws)
|
|
204
|
+
set_trusted(data, ws)
|
|
205
|
+
print(f"made {ws} (saved as the workspace)")
|
|
206
|
+
return str(ws)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main(argv=None):
|
|
210
|
+
if argv is None:
|
|
211
|
+
argv = sys.argv[1:]
|
|
212
|
+
if argv[:1] == ["check"]:
|
|
213
|
+
bad_total = 0
|
|
214
|
+
for d in argv[1:] or ["."]:
|
|
215
|
+
m, bad = extensions.check(d)
|
|
216
|
+
print(f"{'ok ' if not bad else 'NG '} {d}" + (f" ({m['kind']} {m['id']})" if m and not bad else ""))
|
|
217
|
+
for b in bad:
|
|
218
|
+
print(f" - {b}")
|
|
219
|
+
bad_total += len(bad)
|
|
220
|
+
return 1 if bad_total else 0
|
|
221
|
+
if argv[:1] == ["init"]:
|
|
222
|
+
return init(argv[1:])
|
|
223
|
+
if argv[:1] == ["trust"]:
|
|
224
|
+
return trust(argv[1:])
|
|
225
|
+
if argv[:1] == ["setup"]:
|
|
226
|
+
return setup(argv[1:])
|
|
227
|
+
ap = argparse.ArgumentParser(prog="aether", description=f"AetherRSI {VERSION}")
|
|
228
|
+
ap.add_argument("--workspace", default=default_workspace())
|
|
229
|
+
ap.add_argument("--data", default=default_data())
|
|
230
|
+
ap.add_argument("--host", default="127.0.0.1")
|
|
231
|
+
ap.add_argument("--port", type=int, default=int(os.environ.get("AETHER_PORT") or 8030))
|
|
232
|
+
ap.add_argument("--reload", action="store_true", help="swap in changed core code without a restart (after the running jobs end)")
|
|
233
|
+
ap.add_argument("--no-init", action="store_true", help="never ask to make a sample project (the page asks for a folder)")
|
|
234
|
+
a = ap.parse_args(argv)
|
|
235
|
+
ws = ""
|
|
236
|
+
if a.workspace:
|
|
237
|
+
try:
|
|
238
|
+
ws = str(check_workspace(str(Path(a.workspace).expanduser().resolve()))) # a relative --workspace is from where you start
|
|
239
|
+
except ValueError as e:
|
|
240
|
+
print(f"{e}. Pass another --workspace, or none and set it in the page", file=sys.stderr)
|
|
241
|
+
return 2
|
|
242
|
+
elif not a.no_init and not saved_workspace(a.data) and sys.stdin.isatty() and sys.stdout.isatty():
|
|
243
|
+
ask_init(a.data) # saved, not passed: the page's form stays free to change it (a passed one locks it)
|
|
244
|
+
try:
|
|
245
|
+
srv = serve(ws, a.data, a.host, a.port)
|
|
246
|
+
except OSError as e:
|
|
247
|
+
if e.errno not in (errno.EADDRINUSE, errno.EACCES):
|
|
248
|
+
raise
|
|
249
|
+
why = "is in use (another aether, or another program: open it, or stop it)" if e.errno == errno.EADDRINUSE else "is not allowed here"
|
|
250
|
+
print(f"aether: port {a.port} on {a.host} {why}. Start on another one with --port {a.port + 1}", file=sys.stderr)
|
|
251
|
+
return 2
|
|
252
|
+
c = srv.core
|
|
253
|
+
where = c.ws or f"(not set: enter it in the page{' ― ' + c.ws_error if c.ws_error else ''})"
|
|
254
|
+
print(f"AetherRSI {VERSION} http://{a.host}:{a.port}/ workspace={where} core {'locked 🔒' if c.locked else 'UNLOCKED 🔓'} ({c.lock_reason})", flush=True)
|
|
255
|
+
for e in c.info()["errors"]:
|
|
256
|
+
print(f" extension skipped: {e}", flush=True)
|
|
257
|
+
if a.reload:
|
|
258
|
+
from .reload import Reloader
|
|
259
|
+
Reloader(c, argv, log=lambda m: print(m, flush=True)).start()
|
|
260
|
+
try:
|
|
261
|
+
srv.serve_forever()
|
|
262
|
+
except KeyboardInterrupt:
|
|
263
|
+
pass
|
|
264
|
+
return 0
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def cli():
|
|
268
|
+
sys.exit(main())
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
cli()
|
aether/extensions.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Extensions: a folder with an `aether.json` manifest (spec/custom.md). The core only reads manifests; it never imports extension code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import PROTOCOL
|
|
12
|
+
|
|
13
|
+
MANIFEST = "aether.json"
|
|
14
|
+
KINDS = ("preview", "agent", "app")
|
|
15
|
+
ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}(\.[a-z0-9][a-z0-9-]{0,39})?$") # "name" or "author.name" (share with the author part)
|
|
16
|
+
ICON = {".svg": "image/svg+xml", ".png": "image/png"} # an icon goes to the UI as a data: URL in an <img> (no route into the folder, no script runs)
|
|
17
|
+
ICON_MAX = 64 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compatible(v) -> bool:
|
|
21
|
+
"""0.x: the minor must match exactly. From 1.0: same major, minor not newer than the core."""
|
|
22
|
+
try:
|
|
23
|
+
major, minor = (int(x) for x in str(v).split(".")[:2])
|
|
24
|
+
cmaj, cmin = (int(x) for x in PROTOCOL.split(".")[:2])
|
|
25
|
+
except ValueError:
|
|
26
|
+
return False
|
|
27
|
+
return (major, minor) == (cmaj, cmin) if cmaj == 0 else major == cmaj and minor <= cmin
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _inside(base: Path, rel) -> bool:
|
|
31
|
+
if not isinstance(rel, str) or not rel or rel.startswith("/") or "\\" in rel:
|
|
32
|
+
return False
|
|
33
|
+
p = (base / rel).resolve()
|
|
34
|
+
return p.is_file() and base.resolve() in p.parents
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _rel(rel) -> bool:
|
|
38
|
+
"""A path inside the folder, written relative to it (it need not exist yet: runs/ appears with the first run)."""
|
|
39
|
+
if not isinstance(rel, str) or not rel.strip() or rel.startswith("/") or "\\" in rel or "\x00" in rel:
|
|
40
|
+
return False
|
|
41
|
+
parts = [x for x in rel.split("/") if x not in ("", ".")]
|
|
42
|
+
return bool(parts) and ".." not in parts
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
LOOPBACK = re.compile(r"^https?://(127\.0\.0\.1|localhost|\[::1\])(:\d{1,5})?(/\S*)?$")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check(folder) -> tuple:
|
|
49
|
+
"""Returns (manifest, problems). An empty list of problems means the extension conforms to the protocol."""
|
|
50
|
+
folder = Path(folder)
|
|
51
|
+
f = folder / MANIFEST
|
|
52
|
+
try:
|
|
53
|
+
m = json.loads(f.read_text(encoding="utf-8"))
|
|
54
|
+
except FileNotFoundError:
|
|
55
|
+
return None, [f"no {MANIFEST}"]
|
|
56
|
+
except ValueError as e:
|
|
57
|
+
return None, [f"{MANIFEST} is not JSON: {e}"]
|
|
58
|
+
if not isinstance(m, dict):
|
|
59
|
+
return None, [f"{MANIFEST} must be an object"]
|
|
60
|
+
bad = []
|
|
61
|
+
if not compatible(m.get("aether")):
|
|
62
|
+
bad.append(f"protocol {m.get('aether')!r} is not supported (core speaks {PROTOCOL})")
|
|
63
|
+
if m.get("kind") not in KINDS:
|
|
64
|
+
bad.append(f"kind must be one of {', '.join(KINDS)}")
|
|
65
|
+
if not ID.match(str(m.get("id") or "")):
|
|
66
|
+
bad.append("id must be lowercase letters, digits and '-' (max 40), optionally prefixed by the author: author.name")
|
|
67
|
+
if not isinstance(m.get("label"), str) or not m["label"].strip():
|
|
68
|
+
bad.append("label is required")
|
|
69
|
+
kind = m.get("kind")
|
|
70
|
+
if kind == "preview" and not _inside(folder, m.get("entry")):
|
|
71
|
+
bad.append("entry must be a file inside the folder")
|
|
72
|
+
if kind == "app":
|
|
73
|
+
if "url" in m: # a page served by a program on this machine (e.g. a live viewer), not a file of the folder
|
|
74
|
+
if not (isinstance(m["url"], str) and LOOPBACK.match(m["url"])):
|
|
75
|
+
bad.append("url must be a page of a server on this machine: http://127.0.0.1:<port>/… (or localhost)")
|
|
76
|
+
elif not _inside(folder, m.get("entry")):
|
|
77
|
+
bad.append("entry must be a file inside the folder (or url: a page of a server on this machine)")
|
|
78
|
+
if kind == "preview":
|
|
79
|
+
if "binary" in m and not isinstance(m["binary"], bool):
|
|
80
|
+
bad.append("binary must be true or false")
|
|
81
|
+
if "render" in m and not (isinstance(m["render"], list) and m["render"] and all(isinstance(x, str) for x in m["render"])):
|
|
82
|
+
bad.append("render must be a list of strings (the command that draws the pages)")
|
|
83
|
+
match = m.get("match")
|
|
84
|
+
if not (isinstance(match, list) and match and all(isinstance(x, str) and (x.startswith(".") or x == "*") for x in match)):
|
|
85
|
+
bad.append('match must be a list of extensions like [".md"] ("*" = any file no other preview takes)')
|
|
86
|
+
if kind == "agent":
|
|
87
|
+
cmd = m.get("command")
|
|
88
|
+
if not (isinstance(cmd, list) and cmd and all(isinstance(x, str) for x in cmd)):
|
|
89
|
+
bad.append("command must be a list of strings")
|
|
90
|
+
for k in ("inject", "guard", "audio", "dispatch"):
|
|
91
|
+
if k in m and not isinstance(m[k], bool):
|
|
92
|
+
bad.append(f"{k} must be true or false")
|
|
93
|
+
if kind == "app" and "ports" in m and not (isinstance(m["ports"], list) and all(isinstance(x, int) and not isinstance(x, bool) and 0 < x < 65536 for x in m["ports"])):
|
|
94
|
+
bad.append("ports must be a list of port numbers (1-65535)")
|
|
95
|
+
if kind == "app" and "agent_rules" in m and not _inside(folder, m["agent_rules"]):
|
|
96
|
+
bad.append("agent_rules must be a file inside the folder")
|
|
97
|
+
if kind == "app" and "history" in m and not (isinstance(m["history"], list) and all(_rel(x) for x in m["history"])):
|
|
98
|
+
bad.append("history must be a list of paths inside the folder (its past data: runs, results, logs)")
|
|
99
|
+
if "icon" in m and not (_inside(folder, m["icon"]) and Path(m["icon"]).suffix.lower() in ICON and (folder / m["icon"]).stat().st_size <= ICON_MAX):
|
|
100
|
+
bad.append(f"icon must be an .svg or .png file inside the folder, up to {ICON_MAX // 1024} KB")
|
|
101
|
+
return m, bad
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def icon_url(m: dict) -> str:
|
|
105
|
+
"""The manifest's icon as a data: URL ("" without one)."""
|
|
106
|
+
if not m.get("icon"):
|
|
107
|
+
return ""
|
|
108
|
+
f = Path(m["dir"]) / m["icon"]
|
|
109
|
+
try:
|
|
110
|
+
return f"data:{ICON[f.suffix.lower()]};base64,{base64.b64encode(f.read_bytes()).decode()}"
|
|
111
|
+
except OSError: # removed since the scan: the label alone
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def folders(d: Path):
|
|
116
|
+
"""<d>/<folder>/ and, one level down, <d>/<group>/<folder>/ (group = a folder without a manifest, e.g. preview/ or agent/)."""
|
|
117
|
+
for f in sorted(p for p in d.iterdir() if p.is_dir() and not p.name.startswith(".")):
|
|
118
|
+
if (f / MANIFEST).is_file():
|
|
119
|
+
yield f, None
|
|
120
|
+
else:
|
|
121
|
+
for g in sorted(p for p in f.iterdir() if p.is_dir() and not p.name.startswith(".")):
|
|
122
|
+
yield g, f.name
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def stamp(dirs) -> tuple:
|
|
126
|
+
"""Changes when an extension folder is added or removed, at either level, or the folder moves under a symlink, or a manifest
|
|
127
|
+
or its icon is edited (the core rescans then ―― 2026-10-18 an "icon" added to claude-code's aether.json never reached the page)."""
|
|
128
|
+
out = []
|
|
129
|
+
for d in map(Path, dirs):
|
|
130
|
+
out.append(str(d.resolve())) # scan() keeps resolved dirs: a link swapped for its target keeps every mtime but not the path
|
|
131
|
+
out.append(d.stat().st_mtime_ns if d.is_dir() else 0)
|
|
132
|
+
if d.is_dir():
|
|
133
|
+
out += [f.stat().st_mtime_ns for f in sorted(d.iterdir()) if f.is_dir() and not (f / MANIFEST).is_file()]
|
|
134
|
+
for folder, _ in folders(d):
|
|
135
|
+
out += [x.stat().st_mtime_ns if x.is_file() else 0 for x in (folder / MANIFEST, *(folder / n for n in sorted(os.listdir(folder)) if Path(n).suffix.lower() in ICON))]
|
|
136
|
+
return tuple(out)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def scan(dirs, locked: bool, spare=()) -> dict:
|
|
140
|
+
"""Reads every extension folder (see folders). First id wins. Apps are not registered here (they live in the file tree).
|
|
141
|
+
spare: folders read last that only fill in ids nobody else has (marked "spare"); one the workspace already has is skipped quietly."""
|
|
142
|
+
out, seen = {"preview": {}, "agent": {}, "errors": []}, {}
|
|
143
|
+
for d, is_spare in [*((d, False) for d in dirs), *((d, True) for d in spare)]:
|
|
144
|
+
d = Path(d)
|
|
145
|
+
if not d.is_dir():
|
|
146
|
+
continue
|
|
147
|
+
for folder, group in folders(d):
|
|
148
|
+
m, bad = check(folder)
|
|
149
|
+
if m is None and bad == [f"no {MANIFEST}"]:
|
|
150
|
+
continue
|
|
151
|
+
name = str(folder.relative_to(d.parent)) # projects/extensions/preview/x vs .aether-rsi/extensions/x
|
|
152
|
+
if m and group in KINDS and m.get("kind") != group:
|
|
153
|
+
bad = [*bad, f"kind {m.get('kind')!r} sits in the {group}/ folder"]
|
|
154
|
+
if bad:
|
|
155
|
+
out["errors"].append(f"{name}: {'; '.join(bad)}")
|
|
156
|
+
continue
|
|
157
|
+
if m["kind"] == "app":
|
|
158
|
+
continue
|
|
159
|
+
if m["id"] in seen:
|
|
160
|
+
if not is_spare:
|
|
161
|
+
out["errors"].append(f"{name}: id {m['id']!r} is already taken by {seen[m['id']]} (rename one to author.name)")
|
|
162
|
+
continue
|
|
163
|
+
seen[m["id"]] = name
|
|
164
|
+
m = dict(m, dir=str(folder.resolve()), **({"spare": True} if is_spare else {}))
|
|
165
|
+
if m["kind"] == "agent" and locked and not m.get("guard"):
|
|
166
|
+
m["disabled"] = 'the core is locked and this agent does not declare "guard": true'
|
|
167
|
+
out[m["kind"]][m["id"]] = m
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def app_of(folder: Path):
|
|
172
|
+
"""The app manifest of a folder in the file tree, or None."""
|
|
173
|
+
m, bad = check(folder)
|
|
174
|
+
return m if m and not bad and m["kind"] == "app" else None
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Claude Code agent (built in) ― Vertex AI only
|
|
2
|
+
|
|
3
|
+
The agent that comes with the core (`aether/fallback/agent/claude-code/`). It puts the Claude Code CLI behind the agent protocol
|
|
4
|
+
([spec/agent.md](../../../../spec/agent.md)). A workspace that has its own `claude-code` in `extensions/` uses that one instead.
|
|
5
|
+
|
|
6
|
+
**It is for Claude on Google Cloud Vertex AI only.** The core switches Claude Code to Vertex (`CLAUDE_CODE_USE_VERTEX=1` and the project,
|
|
7
|
+
region and key) only from `settings.json` `vertex`. Without it Claude Code falls back to whatever sign-in it has of its own, which this
|
|
8
|
+
agent does not support. So before the first message:
|
|
9
|
+
|
|
10
|
+
1. `claude` on this machine (below)
|
|
11
|
+
2. a Google Cloud project that may call Claude on Vertex AI
|
|
12
|
+
3. a service-account key file, its **path** in `settings.json` `vertex.credentials`
|
|
13
|
+
|
|
14
|
+
## 1. Claude Code
|
|
15
|
+
|
|
16
|
+
Install the Claude Code CLI (https://docs.claude.com/en/docs/claude-code/setup) so that `claude --version` works in a terminal.
|
|
17
|
+
The agent looks for `AETHER_CLAUDE` (a path), then `claude` on the PATH, then (macOS) the copy inside the Claude desktop app.
|
|
18
|
+
You do not need to sign in to Claude Code itself: Vertex is the sign-in.
|
|
19
|
+
|
|
20
|
+
## 2. The project: Vertex AI and the Claude models
|
|
21
|
+
|
|
22
|
+
With the gcloud CLI (https://cloud.google.com/sdk/docs/install), signed in as someone who may administer the project:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
gcloud auth login
|
|
26
|
+
PROJECT=your-project-id
|
|
27
|
+
gcloud config set project $PROJECT
|
|
28
|
+
gcloud services enable aiplatform.googleapis.com # the Vertex AI API (the project needs billing)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then turn on the Claude models in the Cloud console: **Vertex AI → Model Garden**, search “Claude”, open the model you want
|
|
32
|
+
(e.g. Claude Opus) and press **Enable** (accept Anthropic's terms). Do it for each model you will use. Vertex serves Claude in the
|
|
33
|
+
`global` region and in some single regions; `global` is the default here.
|
|
34
|
+
|
|
35
|
+
## 3. A key that can only call the model
|
|
36
|
+
|
|
37
|
+
A service account with the **Vertex AI User** role (`roles/aiplatform.user`) and nothing else: the agent process holds the key, so give
|
|
38
|
+
it no more than calling the model.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
gcloud iam service-accounts create aether-agent --display-name "AetherRSI agents"
|
|
42
|
+
gcloud projects add-iam-policy-binding $PROJECT \
|
|
43
|
+
--member "serviceAccount:aether-agent@$PROJECT.iam.gserviceaccount.com" --role roles/aiplatform.user
|
|
44
|
+
mkdir -p ~/.config/aether && chmod 700 ~/.config/aether
|
|
45
|
+
gcloud iam service-accounts keys create ~/.config/aether/vertex-key.json \
|
|
46
|
+
--iam-account aether-agent@$PROJECT.iam.gserviceaccount.com
|
|
47
|
+
chmod 600 ~/.config/aether/vertex-key.json
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Keep the key **outside the workspace and outside any git repository** (`settings.json` itself is git-ignored, the key file is not).
|
|
51
|
+
The core passes only the key's path, never its contents; it never shows or serves the file, and this agent denies Claude Code's own
|
|
52
|
+
Read/Edit/Write on it (`AETHER_SECRETS`). A shell command run by the agent could still read it, which is why the role is the narrow one.
|
|
53
|
+
|
|
54
|
+
If key creation is refused (`iam.disableServiceAccountKeyCreation`, an organisation policy), ask the project's admin for a key, or use
|
|
55
|
+
your own sign-in instead (step 4, `--adc`).
|
|
56
|
+
|
|
57
|
+
## 4. Put the path in settings.json
|
|
58
|
+
|
|
59
|
+
From a terminal (the page shows what is set, it never sets it):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
aether setup vertex --project $PROJECT --region global --key ~/.config/aether/vertex-key.json
|
|
63
|
+
aether setup vertex # no options: shows what is set (project, region, key file or own sign-in)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
From a clone it is `python3 -m aether setup vertex …`. It writes `settings.json` beside the clone (a pip install:
|
|
67
|
+
`~/.aether-rsi/settings.json`; `AETHER_SETTINGS` points elsewhere). Or write it by hand from `settings.example.json`:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"vertex": {
|
|
72
|
+
"credentials": "/abs/path/to/.config/aether/vertex-key.json",
|
|
73
|
+
"project": "your-project-id",
|
|
74
|
+
"region": "global"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The path must be absolute (`~/` works) and the file must exist, or the entry is skipped and shown as an error (`/api/info` `errors`).
|
|
80
|
+
A running core reads the file again by itself: no restart. The agent then gets `GOOGLE_APPLICATION_CREDENTIALS`,
|
|
81
|
+
`ANTHROPIC_VERTEX_PROJECT_ID`, `CLOUD_ML_REGION` and `CLAUDE_CODE_USE_VERTEX=1` ([spec/agent.md §4.1](../../../../spec/agent.md#41-environment)).
|
|
82
|
+
|
|
83
|
+
Your own sign-in instead of a key (Cloud Shell, or after `gcloud auth application-default login` with an account that may call
|
|
84
|
+
Vertex): `aether setup vertex --project $PROJECT --adc`. No key file is kept anywhere; `--key` switches back.
|
|
85
|
+
|
|
86
|
+
## 5. Try it
|
|
87
|
+
|
|
88
|
+
Pick **Claude Code** in the shell and send “say hello”. If it fails, the job's error says why:
|
|
89
|
+
|
|
90
|
+
| Error | What to do |
|
|
91
|
+
|:--|:--|
|
|
92
|
+
| `claude not found` | Step 1, or set `AETHER_CLAUDE` to the path of `claude` (`settings.json` `env`) |
|
|
93
|
+
| `vertex.credentials: no such file …` (in errors) | The path in step 4 is wrong or not absolute |
|
|
94
|
+
| `403` / `PERMISSION_DENIED` | The service account lacks `roles/aiplatform.user`, or the Vertex AI API is off (step 2, 3) |
|
|
95
|
+
| `404` / model not found | The model is not enabled in Model Garden for this project, or not in this region: enable it, or pick one that is with `AETHER_CLAUDE_MODEL` |
|
|
96
|
+
| It answers, but bills your Claude account | `settings.json` has no `vertex`: Claude Code used its own sign-in (step 4) |
|
|
97
|
+
|
|
98
|
+
## Options
|
|
99
|
+
|
|
100
|
+
Set in `settings.json` `env` (or the core's environment):
|
|
101
|
+
|
|
102
|
+
| Variable | What it does |
|
|
103
|
+
|:--|:--|
|
|
104
|
+
| `AETHER_CLAUDE` | The path of `claude` |
|
|
105
|
+
| `AETHER_CLAUDE_MODEL` | The model (`--model`), e.g. one your project has enabled |
|
|
106
|
+
| `AETHER_CLAUDE_PERMISSION` | Claude Code's permission mode (default `acceptEdits`: edits without asking, no shell without asking) |
|
|
107
|
+
|
|
108
|
+
In a workspace the person trusts (`aether trust`), Claude Code also loads the folder's own settings, hooks and MCP servers; elsewhere it
|
|
109
|
+
starts with `--setting-sources user --strict-mcp-config` ([spec/custom.md](../../../../spec/custom.md#safe-by-default)).
|