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.
Files changed (154) hide show
  1. aetherius/__init__.py +42 -0
  2. aetherius/__main__.py +12 -0
  3. aetherius/_contracts/__init__.py +0 -0
  4. aetherius/_contracts/blueprint.schema.json +144 -0
  5. aetherius/acts/__init__.py +1 -0
  6. aetherius/acts/_shared.py +61 -0
  7. aetherius/acts/continuum/__init__.py +7 -0
  8. aetherius/acts/continuum/actions.py +149 -0
  9. aetherius/acts/continuum/bridge.py +159 -0
  10. aetherius/acts/continuum/browser.py +164 -0
  11. aetherius/acts/continuum/debug_overlay.py +65 -0
  12. aetherius/acts/continuum/driver.py +127 -0
  13. aetherius/acts/continuum/human_actions.py +74 -0
  14. aetherius/acts/oracle/__init__.py +1 -0
  15. aetherius/acts/oracle/driver.py +1 -0
  16. aetherius/acts/oracle/locator.py +1 -0
  17. aetherius/acts/oracle/model.py +1 -0
  18. aetherius/acts/oracle/perception.py +1 -0
  19. aetherius/acts/phantom/__init__.py +1 -0
  20. aetherius/acts/phantom/driver.py +1 -0
  21. aetherius/acts/phantom/loop.py +1 -0
  22. aetherius/acts/phantom/memory.py +1 -0
  23. aetherius/acts/phantom/perception.py +1 -0
  24. aetherius/acts/phantom/planner.py +1 -0
  25. aetherius/acts/vector/__init__.py +1 -0
  26. aetherius/acts/vector/auth.py +108 -0
  27. aetherius/acts/vector/client.py +129 -0
  28. aetherius/acts/vector/driver.py +128 -0
  29. aetherius/builder/__init__.py +40 -0
  30. aetherius/builder/catalog.py +93 -0
  31. aetherius/builder/factory.py +243 -0
  32. aetherius/builder/templates.py +150 -0
  33. aetherius/builder/validation.py +120 -0
  34. aetherius/cli.py +236 -0
  35. aetherius/config/__init__.py +1 -0
  36. aetherius/config/secrets.py +71 -0
  37. aetherius/config/settings.py +44 -0
  38. aetherius/console/__init__.py +1 -0
  39. aetherius/console/app.py +66 -0
  40. aetherius/console/console.tcss +97 -0
  41. aetherius/console/daemon_control.py +83 -0
  42. aetherius/console/run_bridge.py +43 -0
  43. aetherius/console/screens/__init__.py +1 -0
  44. aetherius/console/screens/_pending.py +38 -0
  45. aetherius/console/screens/builder/__init__.py +1 -0
  46. aetherius/console/screens/builder/act_picker.py +75 -0
  47. aetherius/console/screens/builder/io_editor.py +167 -0
  48. aetherius/console/screens/builder/options_editor.py +132 -0
  49. aetherius/console/screens/builder/preview.py +57 -0
  50. aetherius/console/screens/builder/screen.py +235 -0
  51. aetherius/console/screens/builder/step_editor.py +249 -0
  52. aetherius/console/screens/catalog.py +52 -0
  53. aetherius/console/screens/home.py +90 -0
  54. aetherius/console/screens/library.py +124 -0
  55. aetherius/console/screens/library_scan.py +99 -0
  56. aetherius/console/screens/recorder.py +193 -0
  57. aetherius/console/screens/runs.py +138 -0
  58. aetherius/console/screens/sessions.py +14 -0
  59. aetherius/console/screens/settings.py +132 -0
  60. aetherius/console/screenshots.py +173 -0
  61. aetherius/console/theme.py +114 -0
  62. aetherius/console/widgets/__init__.py +1 -0
  63. aetherius/console/widgets/event_log.py +44 -0
  64. aetherius/console/widgets/form.py +101 -0
  65. aetherius/console/widgets/json_preview.py +18 -0
  66. aetherius/console/widgets/run_summary.py +82 -0
  67. aetherius/core/__init__.py +1 -0
  68. aetherius/core/actions/__init__.py +1 -0
  69. aetherius/core/actions/base.py +99 -0
  70. aetherius/core/actions/data.py +126 -0
  71. aetherius/core/actions/flow.py +76 -0
  72. aetherius/core/actions/interaction.py +109 -0
  73. aetherius/core/actions/navigation.py +32 -0
  74. aetherius/core/actions/registry.py +70 -0
  75. aetherius/core/actions/spec.py +42 -0
  76. aetherius/core/blueprint/__init__.py +1 -0
  77. aetherius/core/blueprint/loader.py +82 -0
  78. aetherius/core/blueprint/models.py +81 -0
  79. aetherius/core/blueprint/template.py +101 -0
  80. aetherius/core/blueprint/validator.py +45 -0
  81. aetherius/core/driver.py +36 -0
  82. aetherius/core/errors.py +134 -0
  83. aetherius/core/events/__init__.py +1 -0
  84. aetherius/core/events/bus.py +28 -0
  85. aetherius/core/events/models.py +29 -0
  86. aetherius/core/events/sinks.py +55 -0
  87. aetherius/core/extraction/__init__.py +1 -0
  88. aetherius/core/extraction/html_extractor.py +52 -0
  89. aetherius/core/extraction/json_extractor.py +125 -0
  90. aetherius/core/runtime/__init__.py +1 -0
  91. aetherius/core/runtime/context.py +61 -0
  92. aetherius/core/runtime/engine.py +188 -0
  93. aetherius/core/runtime/result.py +39 -0
  94. aetherius/core/runtime/selector.py +1 -0
  95. aetherius/models/__init__.py +1 -0
  96. aetherius/models/registry.py +1 -0
  97. aetherius/models/store/.gitkeep +0 -0
  98. aetherius/recorder/__init__.py +12 -0
  99. aetherius/recorder/_capture_js.py +80 -0
  100. aetherius/recorder/_gesture_js.py +40 -0
  101. aetherius/recorder/_names.py +20 -0
  102. aetherius/recorder/_overlay_js.py +262 -0
  103. aetherius/recorder/_playwright.py +49 -0
  104. aetherius/recorder/_selector_js.py +148 -0
  105. aetherius/recorder/_transform.py +214 -0
  106. aetherius/recorder/_vector_js.py +174 -0
  107. aetherius/recorder/base.py +92 -0
  108. aetherius/recorder/blueprint_recorder.py +72 -0
  109. aetherius/recorder/capture.py +59 -0
  110. aetherius/recorder/continuum_backend.py +119 -0
  111. aetherius/recorder/gesture_recorder.py +193 -0
  112. aetherius/recorder/selector_synth.py +83 -0
  113. aetherius/recorder/session.py +101 -0
  114. aetherius/recorder/vector_backend.py +158 -0
  115. aetherius/server/__init__.py +12 -0
  116. aetherius/server/app.py +37 -0
  117. aetherius/server/config.py +25 -0
  118. aetherius/server/deps.py +50 -0
  119. aetherius/server/jobs.py +155 -0
  120. aetherius/server/routes/__init__.py +1 -0
  121. aetherius/server/routes/blueprints.py +45 -0
  122. aetherius/server/routes/recorder.py +25 -0
  123. aetherius/server/routes/runs.py +49 -0
  124. aetherius/server/routes/stream.py +53 -0
  125. aetherius/server/schemas.py +78 -0
  126. aetherius/stealth/__init__.py +1 -0
  127. aetherius/stealth/fingerprint/__init__.py +1 -0
  128. aetherius/stealth/fingerprint/patch.py +38 -0
  129. aetherius/stealth/fingerprint/profile.py +95 -0
  130. aetherius/stealth/gestures/__init__.py +1 -0
  131. aetherius/stealth/gestures/data/human_library.json +7107 -0
  132. aetherius/stealth/gestures/library.py +126 -0
  133. aetherius/stealth/gestures/seed.py +97 -0
  134. aetherius/stealth/humanizer/__init__.py +1 -0
  135. aetherius/stealth/humanizer/input.py +89 -0
  136. aetherius/stealth/humanizer/keyboard.py +75 -0
  137. aetherius/stealth/humanizer/mouse.py +153 -0
  138. aetherius/stealth/humanizer/scroll.py +55 -0
  139. aetherius/stealth/humanizer/timing.py +54 -0
  140. aetherius/stealth/ml/__init__.py +1 -0
  141. aetherius/stealth/ml/fingerprint_model.py +1 -0
  142. aetherius/stealth/ml/motion_model.py +1 -0
  143. aetherius/stealth/policy.py +133 -0
  144. aetherius/stealth/session/__init__.py +1 -0
  145. aetherius/stealth/session/store.py +35 -0
  146. aetherius/stealth/session/warmup.py +52 -0
  147. aetherius/version.py +3 -0
  148. aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
  149. aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
  150. aetherius-0.2.0.dist-info/METADATA +606 -0
  151. aetherius-0.2.0.dist-info/RECORD +154 -0
  152. aetherius-0.2.0.dist-info/WHEEL +4 -0
  153. aetherius-0.2.0.dist-info/entry_points.txt +2 -0
  154. 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."""
@@ -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())