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.
- aether_rsi-0.1.0/LICENSE +21 -0
- aether_rsi-0.1.0/PKG-INFO +58 -0
- aether_rsi-0.1.0/README.md +48 -0
- aether_rsi-0.1.0/aether/__init__.py +5 -0
- aether_rsi-0.1.0/aether/__main__.py +272 -0
- aether_rsi-0.1.0/aether/extensions.py +174 -0
- aether_rsi-0.1.0/aether/fallback/agent/claude-code/README.md +109 -0
- aether_rsi-0.1.0/aether/fallback/agent/claude-code/aether.json +9 -0
- aether_rsi-0.1.0/aether/fallback/agent/claude-code/agent.py +148 -0
- aether_rsi-0.1.0/aether/fallback/preview/text-preview/aether.json +9 -0
- aether_rsi-0.1.0/aether/fallback/preview/text-preview/index.html +81 -0
- aether_rsi-0.1.0/aether/files.py +284 -0
- aether_rsi-0.1.0/aether/paste.py +108 -0
- aether_rsi-0.1.0/aether/reload.py +99 -0
- aether_rsi-0.1.0/aether/render.py +191 -0
- aether_rsi-0.1.0/aether/runner.py +450 -0
- aether_rsi-0.1.0/aether/sample_project/AGENTS.md +7 -0
- aether_rsi-0.1.0/aether/sample_project/README.md +17 -0
- aether_rsi-0.1.0/aether/sample_project/apps/hello-app/AGENTS.md +7 -0
- aether_rsi-0.1.0/aether/sample_project/apps/hello-app/aether.json +8 -0
- aether_rsi-0.1.0/aether/sample_project/apps/hello-app/data.json +6 -0
- aether_rsi-0.1.0/aether/sample_project/apps/hello-app/index.html +42 -0
- aether_rsi-0.1.0/aether/sample_project/extensions/agent/echo-agent/aether.json +13 -0
- aether_rsi-0.1.0/aether/sample_project/extensions/agent/echo-agent/agent.py +86 -0
- aether_rsi-0.1.0/aether/sample_project/extensions/preview/json-preview/aether.json +8 -0
- aether_rsi-0.1.0/aether/sample_project/extensions/preview/json-preview/index.html +72 -0
- aether_rsi-0.1.0/aether/sample_project/samples/NOEXTENSION +1 -0
- aether_rsi-0.1.0/aether/sample_project/samples/README.md +14 -0
- aether_rsi-0.1.0/aether/sample_project/samples/json-all.json +76 -0
- aether_rsi-0.1.0/aether/sample_project/samples/json-broken.json +5 -0
- aether_rsi-0.1.0/aether/sample_project/samples/json-large.json +36405 -0
- aether_rsi-0.1.0/aether/sample_project/samples/text-binary.bin +0 -0
- aether_rsi-0.1.0/aether/sample_project/samples/text-code.py +6 -0
- aether_rsi-0.1.0/aether/sample_project/samples/text-crlf.txt +3 -0
- aether_rsi-0.1.0/aether/sample_project/samples/text-empty.txt +0 -0
- aether_rsi-0.1.0/aether/sample_project/samples/text-image.png +0 -0
- aether_rsi-0.1.0/aether/sample_project/samples/text-latin1.txt +1 -0
- aether_rsi-0.1.0/aether/sample_project/samples/text-long.log +20500 -0
- aether_rsi-0.1.0/aether/sample_project/samples/text-table.csv +4 -0
- aether_rsi-0.1.0/aether/server.py +901 -0
- aether_rsi-0.1.0/aether/settings.py +103 -0
- aether_rsi-0.1.0/aether/static/aether-sdk.js +183 -0
- aether_rsi-0.1.0/aether/static/core.css +435 -0
- aether_rsi-0.1.0/aether/static/core.js +1683 -0
- aether_rsi-0.1.0/aether/static/icons.js +146 -0
- aether_rsi-0.1.0/aether/static/index.html +95 -0
- aether_rsi-0.1.0/aether/store.py +314 -0
- aether_rsi-0.1.0/aether/voice.py +204 -0
- aether_rsi-0.1.0/aether_rsi.egg-info/PKG-INFO +58 -0
- aether_rsi-0.1.0/aether_rsi.egg-info/SOURCES.txt +53 -0
- aether_rsi-0.1.0/aether_rsi.egg-info/dependency_links.txt +1 -0
- aether_rsi-0.1.0/aether_rsi.egg-info/entry_points.txt +2 -0
- aether_rsi-0.1.0/aether_rsi.egg-info/top_level.txt +1 -0
- aether_rsi-0.1.0/pyproject.toml +25 -0
- aether_rsi-0.1.0/setup.cfg +4 -0
aether_rsi-0.1.0/LICENSE
ADDED
|
@@ -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,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
|