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.
Files changed (51) hide show
  1. aether/__init__.py +5 -0
  2. aether/__main__.py +272 -0
  3. aether/extensions.py +174 -0
  4. aether/fallback/agent/claude-code/README.md +109 -0
  5. aether/fallback/agent/claude-code/aether.json +9 -0
  6. aether/fallback/agent/claude-code/agent.py +148 -0
  7. aether/fallback/preview/text-preview/aether.json +9 -0
  8. aether/fallback/preview/text-preview/index.html +81 -0
  9. aether/files.py +284 -0
  10. aether/paste.py +108 -0
  11. aether/reload.py +99 -0
  12. aether/render.py +191 -0
  13. aether/runner.py +450 -0
  14. aether/sample_project/AGENTS.md +7 -0
  15. aether/sample_project/README.md +17 -0
  16. aether/sample_project/apps/hello-app/AGENTS.md +7 -0
  17. aether/sample_project/apps/hello-app/aether.json +8 -0
  18. aether/sample_project/apps/hello-app/data.json +6 -0
  19. aether/sample_project/apps/hello-app/index.html +42 -0
  20. aether/sample_project/extensions/agent/echo-agent/aether.json +13 -0
  21. aether/sample_project/extensions/agent/echo-agent/agent.py +86 -0
  22. aether/sample_project/extensions/preview/json-preview/aether.json +8 -0
  23. aether/sample_project/extensions/preview/json-preview/index.html +72 -0
  24. aether/sample_project/samples/NOEXTENSION +1 -0
  25. aether/sample_project/samples/README.md +14 -0
  26. aether/sample_project/samples/json-all.json +76 -0
  27. aether/sample_project/samples/json-broken.json +5 -0
  28. aether/sample_project/samples/json-large.json +36405 -0
  29. aether/sample_project/samples/text-binary.bin +0 -0
  30. aether/sample_project/samples/text-code.py +6 -0
  31. aether/sample_project/samples/text-crlf.txt +3 -0
  32. aether/sample_project/samples/text-empty.txt +0 -0
  33. aether/sample_project/samples/text-image.png +0 -0
  34. aether/sample_project/samples/text-latin1.txt +1 -0
  35. aether/sample_project/samples/text-long.log +20500 -0
  36. aether/sample_project/samples/text-table.csv +4 -0
  37. aether/server.py +901 -0
  38. aether/settings.py +103 -0
  39. aether/static/aether-sdk.js +183 -0
  40. aether/static/core.css +435 -0
  41. aether/static/core.js +1683 -0
  42. aether/static/icons.js +146 -0
  43. aether/static/index.html +95 -0
  44. aether/store.py +314 -0
  45. aether/voice.py +204 -0
  46. aether_rsi-0.1.0.dist-info/METADATA +58 -0
  47. aether_rsi-0.1.0.dist-info/RECORD +51 -0
  48. aether_rsi-0.1.0.dist-info/WHEEL +5 -0
  49. aether_rsi-0.1.0.dist-info/entry_points.txt +2 -0
  50. aether_rsi-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. aether_rsi-0.1.0.dist-info/top_level.txt +1 -0
aether/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """AetherRSI core: a message queue for coding agents, a task list and a file tree.
2
+ Everything else (previews, agents, apps) is an extension that speaks the protocol in spec/ (custom.md, preview.md, agent.md, app.md)."""
3
+
4
+ VERSION = "0.1.0"
5
+ PROTOCOL = "0.1"
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)).
@@ -0,0 +1,9 @@
1
+ {
2
+ "aether": "0.1",
3
+ "kind": "agent",
4
+ "id": "claude-code",
5
+ "label": "Claude Code",
6
+ "command": ["{python}", "{dir}/agent.py"],
7
+ "inject": true,
8
+ "guard": true
9
+ }