aetherius 0.2.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.
- aetherius/__init__.py +42 -0
- aetherius/__main__.py +12 -0
- aetherius/_contracts/__init__.py +0 -0
- aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius/acts/__init__.py +1 -0
- aetherius/acts/_shared.py +61 -0
- aetherius/acts/continuum/__init__.py +7 -0
- aetherius/acts/continuum/actions.py +149 -0
- aetherius/acts/continuum/bridge.py +159 -0
- aetherius/acts/continuum/browser.py +164 -0
- aetherius/acts/continuum/debug_overlay.py +65 -0
- aetherius/acts/continuum/driver.py +127 -0
- aetherius/acts/continuum/human_actions.py +74 -0
- aetherius/acts/oracle/__init__.py +1 -0
- aetherius/acts/oracle/driver.py +1 -0
- aetherius/acts/oracle/locator.py +1 -0
- aetherius/acts/oracle/model.py +1 -0
- aetherius/acts/oracle/perception.py +1 -0
- aetherius/acts/phantom/__init__.py +1 -0
- aetherius/acts/phantom/driver.py +1 -0
- aetherius/acts/phantom/loop.py +1 -0
- aetherius/acts/phantom/memory.py +1 -0
- aetherius/acts/phantom/perception.py +1 -0
- aetherius/acts/phantom/planner.py +1 -0
- aetherius/acts/vector/__init__.py +1 -0
- aetherius/acts/vector/auth.py +108 -0
- aetherius/acts/vector/client.py +129 -0
- aetherius/acts/vector/driver.py +128 -0
- aetherius/builder/__init__.py +40 -0
- aetherius/builder/catalog.py +93 -0
- aetherius/builder/factory.py +243 -0
- aetherius/builder/templates.py +150 -0
- aetherius/builder/validation.py +120 -0
- aetherius/cli.py +236 -0
- aetherius/config/__init__.py +1 -0
- aetherius/config/secrets.py +71 -0
- aetherius/config/settings.py +44 -0
- aetherius/console/__init__.py +1 -0
- aetherius/console/app.py +66 -0
- aetherius/console/console.tcss +97 -0
- aetherius/console/daemon_control.py +83 -0
- aetherius/console/run_bridge.py +43 -0
- aetherius/console/screens/__init__.py +1 -0
- aetherius/console/screens/_pending.py +38 -0
- aetherius/console/screens/builder/__init__.py +1 -0
- aetherius/console/screens/builder/act_picker.py +75 -0
- aetherius/console/screens/builder/io_editor.py +167 -0
- aetherius/console/screens/builder/options_editor.py +132 -0
- aetherius/console/screens/builder/preview.py +57 -0
- aetherius/console/screens/builder/screen.py +235 -0
- aetherius/console/screens/builder/step_editor.py +249 -0
- aetherius/console/screens/catalog.py +52 -0
- aetherius/console/screens/home.py +90 -0
- aetherius/console/screens/library.py +124 -0
- aetherius/console/screens/library_scan.py +99 -0
- aetherius/console/screens/recorder.py +193 -0
- aetherius/console/screens/runs.py +138 -0
- aetherius/console/screens/sessions.py +14 -0
- aetherius/console/screens/settings.py +132 -0
- aetherius/console/screenshots.py +173 -0
- aetherius/console/theme.py +114 -0
- aetherius/console/widgets/__init__.py +1 -0
- aetherius/console/widgets/event_log.py +44 -0
- aetherius/console/widgets/form.py +101 -0
- aetherius/console/widgets/json_preview.py +18 -0
- aetherius/console/widgets/run_summary.py +82 -0
- aetherius/core/__init__.py +1 -0
- aetherius/core/actions/__init__.py +1 -0
- aetherius/core/actions/base.py +99 -0
- aetherius/core/actions/data.py +126 -0
- aetherius/core/actions/flow.py +76 -0
- aetherius/core/actions/interaction.py +109 -0
- aetherius/core/actions/navigation.py +32 -0
- aetherius/core/actions/registry.py +70 -0
- aetherius/core/actions/spec.py +42 -0
- aetherius/core/blueprint/__init__.py +1 -0
- aetherius/core/blueprint/loader.py +82 -0
- aetherius/core/blueprint/models.py +81 -0
- aetherius/core/blueprint/template.py +101 -0
- aetherius/core/blueprint/validator.py +45 -0
- aetherius/core/driver.py +36 -0
- aetherius/core/errors.py +134 -0
- aetherius/core/events/__init__.py +1 -0
- aetherius/core/events/bus.py +28 -0
- aetherius/core/events/models.py +29 -0
- aetherius/core/events/sinks.py +55 -0
- aetherius/core/extraction/__init__.py +1 -0
- aetherius/core/extraction/html_extractor.py +52 -0
- aetherius/core/extraction/json_extractor.py +125 -0
- aetherius/core/runtime/__init__.py +1 -0
- aetherius/core/runtime/context.py +61 -0
- aetherius/core/runtime/engine.py +188 -0
- aetherius/core/runtime/result.py +39 -0
- aetherius/core/runtime/selector.py +1 -0
- aetherius/models/__init__.py +1 -0
- aetherius/models/registry.py +1 -0
- aetherius/models/store/.gitkeep +0 -0
- aetherius/recorder/__init__.py +12 -0
- aetherius/recorder/_capture_js.py +80 -0
- aetherius/recorder/_gesture_js.py +40 -0
- aetherius/recorder/_names.py +20 -0
- aetherius/recorder/_overlay_js.py +262 -0
- aetherius/recorder/_playwright.py +49 -0
- aetherius/recorder/_selector_js.py +148 -0
- aetherius/recorder/_transform.py +214 -0
- aetherius/recorder/_vector_js.py +174 -0
- aetherius/recorder/base.py +92 -0
- aetherius/recorder/blueprint_recorder.py +72 -0
- aetherius/recorder/capture.py +59 -0
- aetherius/recorder/continuum_backend.py +119 -0
- aetherius/recorder/gesture_recorder.py +193 -0
- aetherius/recorder/selector_synth.py +83 -0
- aetherius/recorder/session.py +101 -0
- aetherius/recorder/vector_backend.py +158 -0
- aetherius/server/__init__.py +12 -0
- aetherius/server/app.py +37 -0
- aetherius/server/config.py +25 -0
- aetherius/server/deps.py +50 -0
- aetherius/server/jobs.py +155 -0
- aetherius/server/routes/__init__.py +1 -0
- aetherius/server/routes/blueprints.py +45 -0
- aetherius/server/routes/recorder.py +25 -0
- aetherius/server/routes/runs.py +49 -0
- aetherius/server/routes/stream.py +53 -0
- aetherius/server/schemas.py +78 -0
- aetherius/stealth/__init__.py +1 -0
- aetherius/stealth/fingerprint/__init__.py +1 -0
- aetherius/stealth/fingerprint/patch.py +38 -0
- aetherius/stealth/fingerprint/profile.py +95 -0
- aetherius/stealth/gestures/__init__.py +1 -0
- aetherius/stealth/gestures/data/human_library.json +7107 -0
- aetherius/stealth/gestures/library.py +126 -0
- aetherius/stealth/gestures/seed.py +97 -0
- aetherius/stealth/humanizer/__init__.py +1 -0
- aetherius/stealth/humanizer/input.py +89 -0
- aetherius/stealth/humanizer/keyboard.py +75 -0
- aetherius/stealth/humanizer/mouse.py +153 -0
- aetherius/stealth/humanizer/scroll.py +55 -0
- aetherius/stealth/humanizer/timing.py +54 -0
- aetherius/stealth/ml/__init__.py +1 -0
- aetherius/stealth/ml/fingerprint_model.py +1 -0
- aetherius/stealth/ml/motion_model.py +1 -0
- aetherius/stealth/policy.py +133 -0
- aetherius/stealth/session/__init__.py +1 -0
- aetherius/stealth/session/store.py +35 -0
- aetherius/stealth/session/warmup.py +52 -0
- aetherius/version.py +3 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius-0.2.0.dist-info/METADATA +606 -0
- aetherius-0.2.0.dist-info/RECORD +154 -0
- aetherius-0.2.0.dist-info/WHEEL +4 -0
- aetherius-0.2.0.dist-info/entry_points.txt +2 -0
- aetherius-0.2.0.dist-info/licenses/LICENSE +11 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Starter Blueprint templates per Act and per common use case.
|
|
2
|
+
|
|
3
|
+
Each template is a small, zero-config demonstration against a public endpoint, returned as a fresh
|
|
4
|
+
:class:`~.factory.BlueprintDraft` the Studio can then edit. Every template is guaranteed valid by a
|
|
5
|
+
test that runs it through the canonical build path, so "Load a template" always yields a runnable
|
|
6
|
+
starting point.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
from ..core.errors import BuilderError
|
|
15
|
+
from .factory import BlueprintDraft, StepDraft
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class TemplateInfo:
|
|
20
|
+
"""A template's identity for the picker: a stable key, its Act, a title and a one-liner."""
|
|
21
|
+
|
|
22
|
+
key: str
|
|
23
|
+
act: str
|
|
24
|
+
title: str
|
|
25
|
+
summary: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _vector_api_fetch() -> BlueprintDraft:
|
|
29
|
+
return BlueprintDraft(
|
|
30
|
+
name="api.fetch",
|
|
31
|
+
act="vector",
|
|
32
|
+
description="Fetch a JSON collection over HTTP and extract fields.",
|
|
33
|
+
steps=[
|
|
34
|
+
StepDraft(
|
|
35
|
+
action="http.request",
|
|
36
|
+
id="fetch",
|
|
37
|
+
params={
|
|
38
|
+
"method": "GET",
|
|
39
|
+
"url": "https://jsonplaceholder.typicode.com/posts",
|
|
40
|
+
"expect": {"status": 200},
|
|
41
|
+
"extract": {
|
|
42
|
+
"posts": {
|
|
43
|
+
"from": "json",
|
|
44
|
+
"path": "$[*]",
|
|
45
|
+
"fields": {"id": "$.id", "title": "$.title"},
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
],
|
|
51
|
+
outputs={"posts": "{{ steps.fetch.posts }}"},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _continuum_scrape() -> BlueprintDraft:
|
|
56
|
+
return BlueprintDraft(
|
|
57
|
+
name="site.scrape",
|
|
58
|
+
act="continuum",
|
|
59
|
+
description="Open a page, wait for content, and extract a list of records.",
|
|
60
|
+
steps=[
|
|
61
|
+
StepDraft(action="navigate", params={"url": "https://quotes.toscrape.com/"}),
|
|
62
|
+
StepDraft(action="wait_for", params={"selector": ".quote"}),
|
|
63
|
+
StepDraft(
|
|
64
|
+
action="extract",
|
|
65
|
+
id="data",
|
|
66
|
+
params={
|
|
67
|
+
"outputs": {
|
|
68
|
+
"quotes": {
|
|
69
|
+
"each": ".quote",
|
|
70
|
+
"fields": {
|
|
71
|
+
"text": {"selector": ".text", "as": "text"},
|
|
72
|
+
"author": {"selector": ".author", "as": "text"},
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
),
|
|
78
|
+
],
|
|
79
|
+
outputs={"quotes": "{{ steps.data.quotes }}"},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _continuum_login() -> BlueprintDraft:
|
|
84
|
+
return BlueprintDraft(
|
|
85
|
+
name="site.login",
|
|
86
|
+
act="continuum",
|
|
87
|
+
description="Log in through a form, keeping the password as a runtime secret.",
|
|
88
|
+
inputs={"username": {"type": "string", "required": True}},
|
|
89
|
+
secrets=["password"],
|
|
90
|
+
steps=[
|
|
91
|
+
StepDraft(action="navigate", params={"url": "https://quotes.toscrape.com/login"}),
|
|
92
|
+
StepDraft(
|
|
93
|
+
action="fill", params={"selector": "#username", "value": "{{ inputs.username }}"}
|
|
94
|
+
),
|
|
95
|
+
StepDraft(
|
|
96
|
+
action="fill", params={"selector": "#password", "value": "{{ secrets.password }}"}
|
|
97
|
+
),
|
|
98
|
+
StepDraft(action="click", params={"selector": "input[type='submit']"}),
|
|
99
|
+
StepDraft(
|
|
100
|
+
action="wait_for",
|
|
101
|
+
params={"selector": "a[href='/logout']", "on_timeout": "fail:LOGIN_FAILED"},
|
|
102
|
+
),
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# key -> (TemplateInfo, builder). The builder returns a fresh draft on every call.
|
|
108
|
+
_TEMPLATES: dict[str, tuple[TemplateInfo, Callable[[], BlueprintDraft]]] = {
|
|
109
|
+
"vector.api-fetch": (
|
|
110
|
+
TemplateInfo(
|
|
111
|
+
"vector.api-fetch",
|
|
112
|
+
"vector",
|
|
113
|
+
"API fetch",
|
|
114
|
+
"GET a JSON collection and extract fields (jsonplaceholder).",
|
|
115
|
+
),
|
|
116
|
+
_vector_api_fetch,
|
|
117
|
+
),
|
|
118
|
+
"continuum.scrape": (
|
|
119
|
+
TemplateInfo(
|
|
120
|
+
"continuum.scrape",
|
|
121
|
+
"continuum",
|
|
122
|
+
"Scrape a page",
|
|
123
|
+
"Navigate, wait, extract records (quotes.toscrape.com).",
|
|
124
|
+
),
|
|
125
|
+
_continuum_scrape,
|
|
126
|
+
),
|
|
127
|
+
"continuum.login": (
|
|
128
|
+
TemplateInfo(
|
|
129
|
+
"continuum.login",
|
|
130
|
+
"continuum",
|
|
131
|
+
"Form login",
|
|
132
|
+
"Fill a login form with an input and a secret (quotes.toscrape.com).",
|
|
133
|
+
),
|
|
134
|
+
_continuum_login,
|
|
135
|
+
),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def list_templates(act: str | None = None) -> list[TemplateInfo]:
|
|
140
|
+
"""Every template, optionally filtered to a single *act*, in registration order."""
|
|
141
|
+
infos = [info for info, _ in _TEMPLATES.values()]
|
|
142
|
+
return [info for info in infos if act is None or info.act == act]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def template_draft(key: str) -> BlueprintDraft:
|
|
146
|
+
"""Return a fresh draft for template *key* or raise :class:`BuilderError`."""
|
|
147
|
+
entry = _TEMPLATES.get(key)
|
|
148
|
+
if entry is None:
|
|
149
|
+
raise BuilderError(f"Unknown template {key!r} (known: {sorted(_TEMPLATES)}).")
|
|
150
|
+
return entry[1]()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Incremental Blueprint validation for the live editor.
|
|
2
|
+
|
|
3
|
+
:func:`validate_draft` never raises: it returns a flat list of :class:`ValidationIssue` (errors and
|
|
4
|
+
warnings) that the Studio's preview renders on every change. It layers the same checks the runtime
|
|
5
|
+
enforces — JSON Schema structure, the Pydantic model, then the semantic Act/action rules — so what
|
|
6
|
+
reads as valid here is what will actually load and run. Finalisation (raising) stays in
|
|
7
|
+
``factory.build_blueprint``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
14
|
+
|
|
15
|
+
from jsonschema import Draft202012Validator
|
|
16
|
+
from pydantic import ValidationError
|
|
17
|
+
|
|
18
|
+
from ..core.actions.base import ACT_CAPABILITIES, PENDING_ACTIONS, Capability
|
|
19
|
+
from ..core.actions.registry import action_specs
|
|
20
|
+
from ..core.blueprint.loader import blueprint_schema
|
|
21
|
+
from ..core.blueprint.models import Blueprint
|
|
22
|
+
from ..core.runtime.engine import IMPLEMENTED_ACTS
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .factory import BlueprintDraft
|
|
26
|
+
|
|
27
|
+
Severity = Literal["error", "warning"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ValidationIssue:
|
|
32
|
+
"""One problem found in a draft: its severity, a dotted path, and a human message."""
|
|
33
|
+
|
|
34
|
+
severity: Severity
|
|
35
|
+
path: str
|
|
36
|
+
message: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _format_path(parts: Any) -> str:
|
|
40
|
+
"""Render a jsonschema/pydantic location (['steps', 2, 'url']) as 'steps[2].url'."""
|
|
41
|
+
out = ""
|
|
42
|
+
for part in parts:
|
|
43
|
+
if isinstance(part, int):
|
|
44
|
+
out += f"[{part}]"
|
|
45
|
+
else:
|
|
46
|
+
out += f".{part}" if out else str(part)
|
|
47
|
+
return out or "<root>"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _missing(value: Any) -> bool:
|
|
51
|
+
"""True when a required parameter is effectively empty (absent, blank, or empty container)."""
|
|
52
|
+
if value is None:
|
|
53
|
+
return True
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
return value.strip() == ""
|
|
56
|
+
if isinstance(value, (dict, list)):
|
|
57
|
+
return len(value) == 0
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _semantic_issues(draft: "BlueprintDraft") -> list[ValidationIssue]:
|
|
62
|
+
"""Act/action rules the JSON Schema deliberately leaves open (steps are additionalProperties)."""
|
|
63
|
+
issues: list[ValidationIssue] = []
|
|
64
|
+
caps = ACT_CAPABILITIES.get(draft.act)
|
|
65
|
+
specs = action_specs()
|
|
66
|
+
pending: frozenset[Capability] = PENDING_ACTIONS.get(draft.act, frozenset())
|
|
67
|
+
|
|
68
|
+
if draft.act and draft.act not in IMPLEMENTED_ACTS:
|
|
69
|
+
issues.append(ValidationIssue("warning", "act", f"Act {draft.act!r} is not runnable yet."))
|
|
70
|
+
|
|
71
|
+
for index, step in enumerate(draft.steps):
|
|
72
|
+
base = f"steps[{index}]"
|
|
73
|
+
action = step.action
|
|
74
|
+
if not action:
|
|
75
|
+
issues.append(ValidationIssue("error", base, "Step is missing an action."))
|
|
76
|
+
continue
|
|
77
|
+
if action not in specs:
|
|
78
|
+
issues.append(ValidationIssue("error", base, f"Unknown action {action!r}."))
|
|
79
|
+
continue
|
|
80
|
+
if caps is not None and action not in {c.value for c in caps}:
|
|
81
|
+
issues.append(
|
|
82
|
+
ValidationIssue(
|
|
83
|
+
"error", base, f"Action {action!r} is not supported by act {draft.act!r}."
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
continue
|
|
87
|
+
cap = Capability(action)
|
|
88
|
+
if cap in pending:
|
|
89
|
+
issues.append(
|
|
90
|
+
ValidationIssue("warning", base, f"Action {action!r} is not runnable yet.")
|
|
91
|
+
)
|
|
92
|
+
continue
|
|
93
|
+
for param in specs[action].required_params():
|
|
94
|
+
if _missing(step.params.get(param.name)):
|
|
95
|
+
issues.append(
|
|
96
|
+
ValidationIssue("error", f"{base}.{param.name}", f"'{param.name}' is required.")
|
|
97
|
+
)
|
|
98
|
+
return issues
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_draft(draft: "BlueprintDraft") -> list[ValidationIssue]:
|
|
102
|
+
"""Return every issue in *draft*, most structural first. Never raises."""
|
|
103
|
+
data = draft.to_data()
|
|
104
|
+
issues: list[ValidationIssue] = []
|
|
105
|
+
|
|
106
|
+
validator = Draft202012Validator(blueprint_schema())
|
|
107
|
+
schema_errors = sorted(validator.iter_errors(data), key=lambda e: list(e.path))
|
|
108
|
+
for error in schema_errors:
|
|
109
|
+
issues.append(ValidationIssue("error", _format_path(error.path), error.message))
|
|
110
|
+
|
|
111
|
+
# Only run the Pydantic model when the schema is clean, to avoid reporting the same shape twice.
|
|
112
|
+
if not schema_errors:
|
|
113
|
+
try:
|
|
114
|
+
Blueprint.model_validate(data)
|
|
115
|
+
except ValidationError as exc:
|
|
116
|
+
for err in exc.errors():
|
|
117
|
+
issues.append(ValidationIssue("error", _format_path(err["loc"]), err["msg"]))
|
|
118
|
+
|
|
119
|
+
issues.extend(_semantic_issues(draft))
|
|
120
|
+
return issues
|
aetherius/cli.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Command-line entry point.
|
|
2
|
+
|
|
3
|
+
Running ``aetherius`` with no argument opens the interactive Console (the terminal control center).
|
|
4
|
+
Subcommands (``run``, ``validate``, ``serve``, ``record``) are the scriptable, non-interactive paths.
|
|
5
|
+
|
|
6
|
+
Kept dependency-light on purpose: heavy imports (Textual for the Console, the Aetherius facade)
|
|
7
|
+
happen inside the relevant command, not at module import time.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(add_completion=False, no_args_is_help=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_pairs(pairs: list[str], *, label: str) -> dict[str, str]:
|
|
21
|
+
"""Parse a list of ``key=value`` CLI options into a dict.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
typer.BadParameter: an entry has no ``=``.
|
|
25
|
+
"""
|
|
26
|
+
result: dict[str, str] = {}
|
|
27
|
+
for pair in pairs:
|
|
28
|
+
if "=" not in pair:
|
|
29
|
+
raise typer.BadParameter(f"Malformed {label} {pair!r}: expected key=value.")
|
|
30
|
+
key, _, value = pair.partition("=")
|
|
31
|
+
result[key] = value
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.callback(invoke_without_command=True)
|
|
36
|
+
def main_callback(ctx: typer.Context) -> None:
|
|
37
|
+
"""Dispatch to the Console when no subcommand is given."""
|
|
38
|
+
if ctx.invoked_subcommand is None:
|
|
39
|
+
console()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
def console() -> None:
|
|
44
|
+
"""Open the interactive terminal Console."""
|
|
45
|
+
from .console.app import AetheriusConsoleApp
|
|
46
|
+
|
|
47
|
+
AetheriusConsoleApp().run()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command()
|
|
51
|
+
def run(
|
|
52
|
+
blueprint: Path,
|
|
53
|
+
input: list[str] = typer.Option([], "--input", help="Blueprint input as key=value."),
|
|
54
|
+
secret: list[str] = typer.Option([], "--secret", help="Runtime secret as key=value."),
|
|
55
|
+
debug: bool = typer.Option(False, "--debug", help="Force verbose step-by-step logging."),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Execute a Blueprint file and print its result."""
|
|
58
|
+
from rich.console import Console as RichConsole
|
|
59
|
+
from rich.table import Table
|
|
60
|
+
|
|
61
|
+
from .core.blueprint.loader import load_blueprint
|
|
62
|
+
from .core.errors import AetheriusError
|
|
63
|
+
from .core.runtime.engine import RunEngine
|
|
64
|
+
|
|
65
|
+
rich_console = RichConsole()
|
|
66
|
+
inputs = _parse_pairs(input, label="--input")
|
|
67
|
+
secrets = _parse_pairs(secret, label="--secret")
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
loaded = load_blueprint(blueprint)
|
|
71
|
+
if debug:
|
|
72
|
+
loaded.options.debug = True
|
|
73
|
+
result = RunEngine().run(loaded, inputs=inputs, secrets=secrets)
|
|
74
|
+
except AetheriusError as exc:
|
|
75
|
+
rich_console.print(f"[bold red]Error:[/bold red] {exc}")
|
|
76
|
+
raise typer.Exit(1) from exc
|
|
77
|
+
|
|
78
|
+
table = Table(title=f"{result.blueprint_name} ({result.run_id})")
|
|
79
|
+
table.add_column("Field")
|
|
80
|
+
table.add_column("Value")
|
|
81
|
+
table.add_row("Status", result.status.value)
|
|
82
|
+
table.add_row("Duration", f"{result.duration_ms:.1f} ms")
|
|
83
|
+
if result.error:
|
|
84
|
+
table.add_row("Error", result.error)
|
|
85
|
+
rich_console.print(table)
|
|
86
|
+
|
|
87
|
+
steps = Table(title="Steps")
|
|
88
|
+
steps.add_column("Step")
|
|
89
|
+
steps.add_column("Action")
|
|
90
|
+
steps.add_column("Status")
|
|
91
|
+
steps.add_column("Duration")
|
|
92
|
+
for step in result.step_results:
|
|
93
|
+
steps.add_row(
|
|
94
|
+
step.step_id or "-", step.action, step.status.value, f"{step.duration_ms:.1f} ms"
|
|
95
|
+
)
|
|
96
|
+
rich_console.print(steps)
|
|
97
|
+
|
|
98
|
+
if result.outputs:
|
|
99
|
+
rich_console.print("[bold]Outputs:[/bold]")
|
|
100
|
+
rich_console.print(json.dumps(result.outputs, indent=2, default=str))
|
|
101
|
+
|
|
102
|
+
if result.status.value != "success":
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def validate(blueprint: Path) -> None:
|
|
108
|
+
"""Load a Blueprint and check it against the schema and its Act's capabilities."""
|
|
109
|
+
from rich.console import Console as RichConsole
|
|
110
|
+
|
|
111
|
+
from .core.blueprint.loader import load_blueprint
|
|
112
|
+
from .core.blueprint.validator import validate_for_act
|
|
113
|
+
from .core.errors import AetheriusError
|
|
114
|
+
|
|
115
|
+
rich_console = RichConsole()
|
|
116
|
+
try:
|
|
117
|
+
loaded = load_blueprint(blueprint)
|
|
118
|
+
validate_for_act(loaded)
|
|
119
|
+
except AetheriusError as exc:
|
|
120
|
+
rich_console.print(f"[bold red]Invalid:[/bold red] {exc}")
|
|
121
|
+
raise typer.Exit(1) from exc
|
|
122
|
+
|
|
123
|
+
rich_console.print(
|
|
124
|
+
f"[bold green]OK[/bold green] act={loaded.act!r}, "
|
|
125
|
+
f"{len(loaded.steps)} step(s), {len(loaded.inputs)} input(s)."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command()
|
|
130
|
+
def serve(
|
|
131
|
+
host: str | None = typer.Option(None, "--host", help="Bind address (default 127.0.0.1)."),
|
|
132
|
+
port: int | None = typer.Option(None, "--port", help="Bind port (default 8787)."),
|
|
133
|
+
token: str | None = typer.Option(
|
|
134
|
+
None, "--token", help="Require this bearer token on every request."
|
|
135
|
+
),
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Start the local daemon exposing the engine over HTTP and WebSocket.
|
|
138
|
+
|
|
139
|
+
Options override the environment (`AETHERIUS_DAEMON_HOST/PORT/TOKEN`). The daemon binds to
|
|
140
|
+
loopback by default: it serves local processes only, never the network.
|
|
141
|
+
"""
|
|
142
|
+
import uvicorn
|
|
143
|
+
|
|
144
|
+
from .server import DaemonConfig, create_app
|
|
145
|
+
|
|
146
|
+
# Start from the environment (AETHERIUS_DAEMON_*), then let explicit CLI options win.
|
|
147
|
+
config = DaemonConfig()
|
|
148
|
+
if host is not None:
|
|
149
|
+
config.host = host
|
|
150
|
+
if port is not None:
|
|
151
|
+
config.port = port
|
|
152
|
+
if token is not None:
|
|
153
|
+
config.token = token
|
|
154
|
+
|
|
155
|
+
uvicorn.run(create_app(config), host=config.host, port=config.port, log_level="info")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command()
|
|
159
|
+
def record(
|
|
160
|
+
name: str,
|
|
161
|
+
url: str = typer.Option(..., "--url", help="Start URL for the demonstration."),
|
|
162
|
+
act: str = typer.Option(
|
|
163
|
+
"continuum", "--act", help="Recorder backend: 'continuum' (browser) or 'vector' (API)."
|
|
164
|
+
),
|
|
165
|
+
out: Path | None = typer.Option(
|
|
166
|
+
None, "--out", help="Directory for the recorded Blueprint (default ./blueprints)."
|
|
167
|
+
),
|
|
168
|
+
no_secrets: bool = typer.Option(
|
|
169
|
+
False,
|
|
170
|
+
"--no-secrets",
|
|
171
|
+
help="Keep username-like fields literal (passwords are always secrets).",
|
|
172
|
+
),
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Record a Blueprint by demonstrating a task in a visible browser."""
|
|
175
|
+
from rich.console import Console as RichConsole
|
|
176
|
+
|
|
177
|
+
from .core.errors import AetheriusError
|
|
178
|
+
from .recorder import record_blueprint
|
|
179
|
+
|
|
180
|
+
rich_console = RichConsole()
|
|
181
|
+
rich_console.print(f"[bold]Recording[/bold] {name!r} — a browser opens at {url}.")
|
|
182
|
+
rich_console.print("Demonstrate the task, then close the window to finish.")
|
|
183
|
+
|
|
184
|
+
def on_event(description: str) -> None:
|
|
185
|
+
rich_console.print(f" [dim]{description}[/dim]")
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
path = record_blueprint(
|
|
189
|
+
name,
|
|
190
|
+
url,
|
|
191
|
+
act=act,
|
|
192
|
+
out_dir=out,
|
|
193
|
+
on_event=on_event,
|
|
194
|
+
credentials_as_secrets=not no_secrets,
|
|
195
|
+
)
|
|
196
|
+
except AetheriusError as exc:
|
|
197
|
+
rich_console.print(f"[bold red]Error:[/bold red] {exc}")
|
|
198
|
+
raise typer.Exit(1) from exc
|
|
199
|
+
|
|
200
|
+
rich_console.print(f"[bold green]Saved[/bold green] {path}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command(name="record-gestures")
|
|
204
|
+
def record_gestures_command(
|
|
205
|
+
out: Path | None = typer.Option(
|
|
206
|
+
None, "--out", help="Gesture library file to write (default: bundled human_library.json)."
|
|
207
|
+
),
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Capture real mouse gestures to extend the stealth gesture library."""
|
|
210
|
+
from rich.console import Console as RichConsole
|
|
211
|
+
|
|
212
|
+
from .core.errors import AetheriusError
|
|
213
|
+
from .recorder import record_gestures
|
|
214
|
+
|
|
215
|
+
rich_console = RichConsole()
|
|
216
|
+
rich_console.print("A browser opens. Move and click naturally, then close the window to save.")
|
|
217
|
+
try:
|
|
218
|
+
path, count = record_gestures(out_path=out)
|
|
219
|
+
except AetheriusError as exc:
|
|
220
|
+
rich_console.print(f"[bold red]Error:[/bold red] {exc}")
|
|
221
|
+
raise typer.Exit(1) from exc
|
|
222
|
+
|
|
223
|
+
rich_console.print(f"[bold green]Added[/bold green] {count} gesture(s) to {path}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main(argv: list[str] | None = None) -> int:
|
|
227
|
+
"""Entry point used by the ``aetherius`` console script."""
|
|
228
|
+
try:
|
|
229
|
+
result = app(args=argv, standalone_mode=False)
|
|
230
|
+
except typer.Abort:
|
|
231
|
+
return 1
|
|
232
|
+
return result if isinstance(result, int) else 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Global configuration package."""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Secret resolution: fill a Blueprint's declared secrets from the environment.
|
|
2
|
+
|
|
3
|
+
A Blueprint never stores secret values, only their names. At run time each declared secret is
|
|
4
|
+
resolved, in order of precedence:
|
|
5
|
+
|
|
6
|
+
1. a value passed explicitly by the caller (``run(..., secrets=...)`` / ``--secret``);
|
|
7
|
+
2. an environment variable ``AETHERIUS_SECRET_<NAME>`` (name upper-cased);
|
|
8
|
+
|
|
9
|
+
A local ``.env`` file (git-ignored) is loaded into the environment first, so a developer or another
|
|
10
|
+
agent can keep credentials on their machine without ever committing them to a public repo. Existing
|
|
11
|
+
environment variables always win over the file, which keeps CI and production in control.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Mapping
|
|
17
|
+
|
|
18
|
+
from dotenv import find_dotenv, load_dotenv
|
|
19
|
+
|
|
20
|
+
_SECRET_ENV_PREFIX = "AETHERIUS_SECRET_"
|
|
21
|
+
_dotenv_loaded = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _secret_env_key(name: str) -> str:
|
|
25
|
+
"""Environment variable backing the declared secret *name* (``cas_pass`` -> ...``CAS_PASS``)."""
|
|
26
|
+
return _SECRET_ENV_PREFIX + name.upper()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_dotenv_once() -> None:
|
|
30
|
+
"""Load the nearest ``.env`` into the environment, once per process, without overriding it."""
|
|
31
|
+
global _dotenv_loaded
|
|
32
|
+
if _dotenv_loaded:
|
|
33
|
+
return
|
|
34
|
+
path = find_dotenv(usecwd=True)
|
|
35
|
+
if path:
|
|
36
|
+
load_dotenv(path, override=False)
|
|
37
|
+
_dotenv_loaded = True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_secrets(
|
|
41
|
+
declared: list[str],
|
|
42
|
+
provided: Mapping[str, str] | None,
|
|
43
|
+
) -> dict[str, str]:
|
|
44
|
+
"""Return the secret values for *declared*, merging caller values over environment ones.
|
|
45
|
+
|
|
46
|
+
Secrets left unresolved are simply omitted; the template engine surfaces a clear error if a
|
|
47
|
+
step actually references one that was never provided.
|
|
48
|
+
"""
|
|
49
|
+
import os
|
|
50
|
+
|
|
51
|
+
resolved: dict[str, str] = dict(provided or {})
|
|
52
|
+
missing = [name for name in declared if name not in resolved]
|
|
53
|
+
if missing:
|
|
54
|
+
load_dotenv_once()
|
|
55
|
+
for name in missing:
|
|
56
|
+
value = os.environ.get(_secret_env_key(name))
|
|
57
|
+
if value is not None:
|
|
58
|
+
resolved[name] = value
|
|
59
|
+
return resolved
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def available_from_env(declared: list[str]) -> set[str]:
|
|
63
|
+
"""Subset of *declared* secrets that can be resolved from the environment right now.
|
|
64
|
+
|
|
65
|
+
Used by the Console to mark those fields optional instead of forcing the user to retype them.
|
|
66
|
+
"""
|
|
67
|
+
import os
|
|
68
|
+
|
|
69
|
+
if declared:
|
|
70
|
+
load_dotenv_once()
|
|
71
|
+
return {name for name in declared if os.environ.get(_secret_env_key(name)) is not None}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Environment-driven settings via pydantic-settings.
|
|
2
|
+
|
|
3
|
+
Central place for filesystem locations shared across runs: persistent browser profiles,
|
|
4
|
+
run artifacts (screenshots, HAR, DOM snapshots). Kept intentionally small; it grows as later
|
|
5
|
+
milestones (daemon, sessions warmup) need more knobs. Everything is overridable through the
|
|
6
|
+
``AETHERIUS_`` environment prefix so deployments never hardcode paths.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _default_data_dir() -> Path:
|
|
19
|
+
"""Base directory for Aetherius state, resolved once from the home directory."""
|
|
20
|
+
return Path.home() / ".aetherius"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AetheriusSettings(BaseSettings):
|
|
24
|
+
"""Runtime configuration resolved from the environment (prefix ``AETHERIUS_``)."""
|
|
25
|
+
|
|
26
|
+
model_config = SettingsConfigDict(env_prefix="AETHERIUS_", extra="ignore")
|
|
27
|
+
|
|
28
|
+
data_dir: Path = Field(default_factory=_default_data_dir)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def profiles_dir(self) -> Path:
|
|
32
|
+
"""Where persistent browser profiles live (one subdirectory per named profile)."""
|
|
33
|
+
return self.data_dir / "profiles"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def runs_dir(self) -> Path:
|
|
37
|
+
"""Where per-run artifacts are written (one subdirectory per run id)."""
|
|
38
|
+
return self.data_dir / "runs"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@lru_cache(maxsize=1)
|
|
42
|
+
def get_settings() -> AetheriusSettings:
|
|
43
|
+
"""Return the process-wide settings singleton (cached; reads the environment once)."""
|
|
44
|
+
return AetheriusSettings()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The Aetherius Console: the interactive terminal control center. Never required at execution time."""
|
aetherius/console/app.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Textual application: screen routing and global bindings for the Console."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from textual.app import App
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
|
|
11
|
+
from .theme import AETHERIUS_THEME
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .daemon_control import DaemonController
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AetheriusConsoleApp(App[None]):
|
|
18
|
+
"""The Aetherius Console: navigation shell for Home, Library, Runs, Catalog, Recorder, the
|
|
19
|
+
Blueprint Studio and the daemon Settings (Sessions is still pending)."""
|
|
20
|
+
|
|
21
|
+
CSS_PATH = Path(__file__).parent / "console.tcss"
|
|
22
|
+
TITLE = "Aetherius"
|
|
23
|
+
SUB_TITLE = "Console"
|
|
24
|
+
|
|
25
|
+
BINDINGS = [
|
|
26
|
+
Binding("q", "quit", "Quit"),
|
|
27
|
+
Binding("escape", "pop_screen", "Back"),
|
|
28
|
+
Binding("h", "go_home", "Home"),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
super().__init__()
|
|
33
|
+
# Created lazily the first time Settings is opened; a single session-scoped daemon so its
|
|
34
|
+
# running state survives navigation. Its own atexit guard stops it when the process exits.
|
|
35
|
+
self._daemon: DaemonController | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def daemon(self) -> DaemonController:
|
|
39
|
+
"""The session's local daemon controller (lazily created)."""
|
|
40
|
+
if self._daemon is None:
|
|
41
|
+
from .daemon_control import DaemonController
|
|
42
|
+
|
|
43
|
+
self._daemon = DaemonController()
|
|
44
|
+
return self._daemon
|
|
45
|
+
|
|
46
|
+
def on_mount(self) -> None:
|
|
47
|
+
self.register_theme(AETHERIUS_THEME)
|
|
48
|
+
self.theme = "aetherius"
|
|
49
|
+
|
|
50
|
+
from .screens.home import HomeScreen
|
|
51
|
+
|
|
52
|
+
self.push_screen(HomeScreen())
|
|
53
|
+
|
|
54
|
+
async def action_pop_screen(self) -> None:
|
|
55
|
+
# screen_stack[0] is Textual's own default screen, never shown to the user (HomeScreen
|
|
56
|
+
# is pushed on top of it in on_mount) — never pop back down to it.
|
|
57
|
+
if len(self.screen_stack) > 2:
|
|
58
|
+
self.pop_screen()
|
|
59
|
+
|
|
60
|
+
def action_go_home(self) -> None:
|
|
61
|
+
from .screens.home import HomeScreen
|
|
62
|
+
|
|
63
|
+
while len(self.screen_stack) > 1:
|
|
64
|
+
self.pop_screen()
|
|
65
|
+
if not isinstance(self.screen, HomeScreen):
|
|
66
|
+
self.push_screen(HomeScreen())
|