aether-rsi 0.1.0__tar.gz

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 (55) hide show
  1. aether_rsi-0.1.0/LICENSE +21 -0
  2. aether_rsi-0.1.0/PKG-INFO +58 -0
  3. aether_rsi-0.1.0/README.md +48 -0
  4. aether_rsi-0.1.0/aether/__init__.py +5 -0
  5. aether_rsi-0.1.0/aether/__main__.py +272 -0
  6. aether_rsi-0.1.0/aether/extensions.py +174 -0
  7. aether_rsi-0.1.0/aether/fallback/agent/claude-code/README.md +109 -0
  8. aether_rsi-0.1.0/aether/fallback/agent/claude-code/aether.json +9 -0
  9. aether_rsi-0.1.0/aether/fallback/agent/claude-code/agent.py +148 -0
  10. aether_rsi-0.1.0/aether/fallback/preview/text-preview/aether.json +9 -0
  11. aether_rsi-0.1.0/aether/fallback/preview/text-preview/index.html +81 -0
  12. aether_rsi-0.1.0/aether/files.py +284 -0
  13. aether_rsi-0.1.0/aether/paste.py +108 -0
  14. aether_rsi-0.1.0/aether/reload.py +99 -0
  15. aether_rsi-0.1.0/aether/render.py +191 -0
  16. aether_rsi-0.1.0/aether/runner.py +450 -0
  17. aether_rsi-0.1.0/aether/sample_project/AGENTS.md +7 -0
  18. aether_rsi-0.1.0/aether/sample_project/README.md +17 -0
  19. aether_rsi-0.1.0/aether/sample_project/apps/hello-app/AGENTS.md +7 -0
  20. aether_rsi-0.1.0/aether/sample_project/apps/hello-app/aether.json +8 -0
  21. aether_rsi-0.1.0/aether/sample_project/apps/hello-app/data.json +6 -0
  22. aether_rsi-0.1.0/aether/sample_project/apps/hello-app/index.html +42 -0
  23. aether_rsi-0.1.0/aether/sample_project/extensions/agent/echo-agent/aether.json +13 -0
  24. aether_rsi-0.1.0/aether/sample_project/extensions/agent/echo-agent/agent.py +86 -0
  25. aether_rsi-0.1.0/aether/sample_project/extensions/preview/json-preview/aether.json +8 -0
  26. aether_rsi-0.1.0/aether/sample_project/extensions/preview/json-preview/index.html +72 -0
  27. aether_rsi-0.1.0/aether/sample_project/samples/NOEXTENSION +1 -0
  28. aether_rsi-0.1.0/aether/sample_project/samples/README.md +14 -0
  29. aether_rsi-0.1.0/aether/sample_project/samples/json-all.json +76 -0
  30. aether_rsi-0.1.0/aether/sample_project/samples/json-broken.json +5 -0
  31. aether_rsi-0.1.0/aether/sample_project/samples/json-large.json +36405 -0
  32. aether_rsi-0.1.0/aether/sample_project/samples/text-binary.bin +0 -0
  33. aether_rsi-0.1.0/aether/sample_project/samples/text-code.py +6 -0
  34. aether_rsi-0.1.0/aether/sample_project/samples/text-crlf.txt +3 -0
  35. aether_rsi-0.1.0/aether/sample_project/samples/text-empty.txt +0 -0
  36. aether_rsi-0.1.0/aether/sample_project/samples/text-image.png +0 -0
  37. aether_rsi-0.1.0/aether/sample_project/samples/text-latin1.txt +1 -0
  38. aether_rsi-0.1.0/aether/sample_project/samples/text-long.log +20500 -0
  39. aether_rsi-0.1.0/aether/sample_project/samples/text-table.csv +4 -0
  40. aether_rsi-0.1.0/aether/server.py +901 -0
  41. aether_rsi-0.1.0/aether/settings.py +103 -0
  42. aether_rsi-0.1.0/aether/static/aether-sdk.js +183 -0
  43. aether_rsi-0.1.0/aether/static/core.css +435 -0
  44. aether_rsi-0.1.0/aether/static/core.js +1683 -0
  45. aether_rsi-0.1.0/aether/static/icons.js +146 -0
  46. aether_rsi-0.1.0/aether/static/index.html +95 -0
  47. aether_rsi-0.1.0/aether/store.py +314 -0
  48. aether_rsi-0.1.0/aether/voice.py +204 -0
  49. aether_rsi-0.1.0/aether_rsi.egg-info/PKG-INFO +58 -0
  50. aether_rsi-0.1.0/aether_rsi.egg-info/SOURCES.txt +53 -0
  51. aether_rsi-0.1.0/aether_rsi.egg-info/dependency_links.txt +1 -0
  52. aether_rsi-0.1.0/aether_rsi.egg-info/entry_points.txt +2 -0
  53. aether_rsi-0.1.0/aether_rsi.egg-info/top_level.txt +1 -0
  54. aether_rsi-0.1.0/pyproject.toml +25 -0
  55. aether_rsi-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AetherRSI contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: aether-rsi
3
+ Version: 0.1.0
4
+ Summary: A small, local workbench for coding agents: a message queue, a task list and a file tree. Everything else is an extension.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # AetherRSI
12
+
13
+ A small local workbench for coding agents: **send a message → it queues → an agent runs it → you watch, inject, or stop.**
14
+ The core does three things ― 💬 Messages, 📋 Tasks, 📂 Files. Everything else is an extension that speaks one protocol ([spec/custom.md](spec/custom.md)).
15
+ Python 3.9+ standard library only. No build step, no dependencies.
16
+
17
+ ## Start
18
+
19
+ ```bash
20
+ pip install aether-rsi # or from a clone: pip install .
21
+ aether # http://127.0.0.1:8030/ ― offers a SampleProject on the first run
22
+ ```
23
+
24
+ Python 3.9+ on macOS, Linux or Windows (WSL). From a clone, `python3 -m aether` works too (`--workspace DIR`, `--port N`, `--reload`).
25
+
26
+ ### The built-in Claude Code agent needs Vertex AI
27
+
28
+ The agent that comes with the core, **Claude Code, is for Claude on Google Cloud Vertex AI only**. Before the first message, put the
29
+ **path of a service-account key** in `settings.json` (beside the clone, or `~/.aether-rsi/settings.json` for a pip install):
30
+
31
+ ```bash
32
+ aether setup vertex --project your-project-id --region global --key /abs/path/to/vertex-key.json
33
+ ```
34
+
35
+ How to get the key and enable the models, step by step: [aether/fallback/agent/claude-code/README.md](aether/fallback/agent/claude-code/README.md).
36
+ Without `vertex` in `settings.json`, Claude Code is not switched to Vertex. The **Echo** agent of the SampleProject runs without any of this.
37
+
38
+ ## Extensions
39
+
40
+ A folder with `aether.json`, put in `<workspace>/extensions/` or listed in `settings.json`. Check one with `aether check <folder>`.
41
+
42
+ | Kind | What it is | Guide |
43
+ |:--|:--|:--|
44
+ | 👁 Preview | a page that shows a file type | [spec/preview.md](spec/preview.md) |
45
+ | 🤖 Agent | a program that reads and writes JSON lines | [spec/agent.md](spec/agent.md) |
46
+ | 🧩 App | a folder with a page and rules for the agent | [spec/app.md](spec/app.md) |
47
+
48
+ ## Safe by default
49
+
50
+ Binds to 127.0.0.1, keeps agents out of the core and its data, and runs extension pages in sandboxed iframes.
51
+
52
+ ## More
53
+
54
+ - [spec/custom.md](spec/custom.md) ― customizing in general: the screen, start and workspace, settings.json, the manifest, pages and the bar, safety and the lock, the HTTP API, tests, releases
55
+ - [spec/preview.md](spec/preview.md) · [spec/agent.md](spec/agent.md) (voice, dispatch, the screen) · [spec/app.md](spec/app.md) ― each kind of extension, protocol v0.1 included
56
+ - `conformance/` ― the tests (`python3 -m unittest discover -s conformance -t .`)
57
+
58
+ Status: v0.1 draft. License: MIT ([LICENSE](LICENSE)).
@@ -0,0 +1,48 @@
1
+ # AetherRSI
2
+
3
+ A small local workbench for coding agents: **send a message → it queues → an agent runs it → you watch, inject, or stop.**
4
+ The core does three things ― 💬 Messages, 📋 Tasks, 📂 Files. Everything else is an extension that speaks one protocol ([spec/custom.md](spec/custom.md)).
5
+ Python 3.9+ standard library only. No build step, no dependencies.
6
+
7
+ ## Start
8
+
9
+ ```bash
10
+ pip install aether-rsi # or from a clone: pip install .
11
+ aether # http://127.0.0.1:8030/ ― offers a SampleProject on the first run
12
+ ```
13
+
14
+ Python 3.9+ on macOS, Linux or Windows (WSL). From a clone, `python3 -m aether` works too (`--workspace DIR`, `--port N`, `--reload`).
15
+
16
+ ### The built-in Claude Code agent needs Vertex AI
17
+
18
+ The agent that comes with the core, **Claude Code, is for Claude on Google Cloud Vertex AI only**. Before the first message, put the
19
+ **path of a service-account key** in `settings.json` (beside the clone, or `~/.aether-rsi/settings.json` for a pip install):
20
+
21
+ ```bash
22
+ aether setup vertex --project your-project-id --region global --key /abs/path/to/vertex-key.json
23
+ ```
24
+
25
+ How to get the key and enable the models, step by step: [aether/fallback/agent/claude-code/README.md](aether/fallback/agent/claude-code/README.md).
26
+ Without `vertex` in `settings.json`, Claude Code is not switched to Vertex. The **Echo** agent of the SampleProject runs without any of this.
27
+
28
+ ## Extensions
29
+
30
+ A folder with `aether.json`, put in `<workspace>/extensions/` or listed in `settings.json`. Check one with `aether check <folder>`.
31
+
32
+ | Kind | What it is | Guide |
33
+ |:--|:--|:--|
34
+ | 👁 Preview | a page that shows a file type | [spec/preview.md](spec/preview.md) |
35
+ | 🤖 Agent | a program that reads and writes JSON lines | [spec/agent.md](spec/agent.md) |
36
+ | 🧩 App | a folder with a page and rules for the agent | [spec/app.md](spec/app.md) |
37
+
38
+ ## Safe by default
39
+
40
+ Binds to 127.0.0.1, keeps agents out of the core and its data, and runs extension pages in sandboxed iframes.
41
+
42
+ ## More
43
+
44
+ - [spec/custom.md](spec/custom.md) ― customizing in general: the screen, start and workspace, settings.json, the manifest, pages and the bar, safety and the lock, the HTTP API, tests, releases
45
+ - [spec/preview.md](spec/preview.md) · [spec/agent.md](spec/agent.md) (voice, dispatch, the screen) · [spec/app.md](spec/app.md) ― each kind of extension, protocol v0.1 included
46
+ - `conformance/` ― the tests (`python3 -m unittest discover -s conformance -t .`)
47
+
48
+ Status: v0.1 draft. License: MIT ([LICENSE](LICENSE)).
@@ -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"
@@ -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()
@@ -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