aster-cli 0.1.2__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.
- aster_cli/__init__.py +1 -0
- aster_cli/access.py +270 -0
- aster_cli/aster_service.py +300 -0
- aster_cli/codegen.py +828 -0
- aster_cli/codegen_typescript.py +819 -0
- aster_cli/contract.py +1112 -0
- aster_cli/credentials.py +87 -0
- aster_cli/enroll.py +315 -0
- aster_cli/handle_validation.py +53 -0
- aster_cli/identity.py +194 -0
- aster_cli/init.py +104 -0
- aster_cli/join.py +442 -0
- aster_cli/keygen.py +203 -0
- aster_cli/main.py +15 -0
- aster_cli/mcp/__init__.py +13 -0
- aster_cli/mcp/schema.py +205 -0
- aster_cli/mcp/security.py +108 -0
- aster_cli/mcp/server.py +407 -0
- aster_cli/profile.py +334 -0
- aster_cli/publish.py +598 -0
- aster_cli/shell/__init__.py +17 -0
- aster_cli/shell/app.py +2390 -0
- aster_cli/shell/commands.py +1624 -0
- aster_cli/shell/completer.py +156 -0
- aster_cli/shell/display.py +405 -0
- aster_cli/shell/guide.py +230 -0
- aster_cli/shell/hooks.py +255 -0
- aster_cli/shell/invoker.py +430 -0
- aster_cli/shell/plugin.py +185 -0
- aster_cli/shell/vfs.py +438 -0
- aster_cli/signer.py +150 -0
- aster_cli/templates/llm/python.md +578 -0
- aster_cli/trust.py +244 -0
- aster_cli-0.1.2.dist-info/METADATA +10 -0
- aster_cli-0.1.2.dist-info/RECORD +38 -0
- aster_cli-0.1.2.dist-info/WHEEL +5 -0
- aster_cli-0.1.2.dist-info/entry_points.txt +2 -0
- aster_cli-0.1.2.dist-info/top_level.txt +1 -0
aster_cli/shell/guide.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aster_cli.shell.guide -- First-time guided tour system.
|
|
3
|
+
|
|
4
|
+
Provides step-by-step hints for new users, triggered by their actions.
|
|
5
|
+
The tour state is persisted in ~/.aster/config.toml under [shell] so
|
|
6
|
+
it only runs once.
|
|
7
|
+
|
|
8
|
+
The guide is a sequence of steps, each triggered by a specific event
|
|
9
|
+
(command executed, directory entered, etc.). When a step's trigger fires,
|
|
10
|
+
we show the hint and advance to the next step.
|
|
11
|
+
|
|
12
|
+
Custom tours can be registered for domain-specific workflows.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TourStep:
|
|
25
|
+
"""A single step in a guided tour."""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
trigger: str # event name that triggers this step
|
|
29
|
+
message: str # rich-formatted hint to display
|
|
30
|
+
trigger_value: str | None = None # optional: match on specific value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Tour:
|
|
35
|
+
"""A guided tour -- a sequence of steps shown to first-time users."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
steps: list[TourStep] = field(default_factory=list)
|
|
39
|
+
current_step: int = 0
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_complete(self) -> bool:
|
|
43
|
+
return self.current_step >= len(self.steps)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def current(self) -> TourStep | None:
|
|
47
|
+
if self.is_complete:
|
|
48
|
+
return None
|
|
49
|
+
return self.steps[self.current_step]
|
|
50
|
+
|
|
51
|
+
def advance(self) -> None:
|
|
52
|
+
self.current_step += 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Default tour ──────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
DEFAULT_TOUR = Tour(
|
|
58
|
+
name="welcome",
|
|
59
|
+
steps=[
|
|
60
|
+
TourStep(
|
|
61
|
+
id="welcome",
|
|
62
|
+
trigger="connected",
|
|
63
|
+
message=(
|
|
64
|
+
"[bold cyan]Welcome to the Aster shell![/bold cyan]\n"
|
|
65
|
+
" Try [green]ls[/green] to see what's here."
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
TourStep(
|
|
69
|
+
id="after_ls_root",
|
|
70
|
+
trigger="command",
|
|
71
|
+
trigger_value="ls",
|
|
72
|
+
message=(
|
|
73
|
+
" You can explore [cyan]services/[/cyan], [cyan]blobs/[/cyan], or [cyan]gossip/[/cyan].\n"
|
|
74
|
+
" Try [green]cd services[/green] to browse available RPC services."
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
TourStep(
|
|
78
|
+
id="in_services",
|
|
79
|
+
trigger="cd",
|
|
80
|
+
trigger_value="/services",
|
|
81
|
+
message=(
|
|
82
|
+
" These are the services on this peer.\n"
|
|
83
|
+
" Try [green]ls[/green] to see them, then [green]cd <ServiceName>[/green] to explore one."
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
TourStep(
|
|
87
|
+
id="in_service",
|
|
88
|
+
trigger="cd",
|
|
89
|
+
trigger_value="/services/*",
|
|
90
|
+
message=(
|
|
91
|
+
" You're inside a service. Try [green]ls[/green] to see its methods.\n"
|
|
92
|
+
" You can invoke a method directly: [green]./methodName arg=value[/green]\n"
|
|
93
|
+
" Or use [green]describe[/green] to see the full contract."
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
TourStep(
|
|
97
|
+
id="first_invoke",
|
|
98
|
+
trigger="invoke",
|
|
99
|
+
message=(
|
|
100
|
+
" Nice! You just made your first RPC call.\n"
|
|
101
|
+
" [dim]Tip: methods with no args will prompt you interactively.[/dim]\n"
|
|
102
|
+
" [dim]Use Tab for autocomplete anywhere.[/dim]\n"
|
|
103
|
+
"\n"
|
|
104
|
+
" [bold]You're all set![/bold] Type [green]help[/green] anytime to see available commands."
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── Guide manager ─────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
class GuideManager:
|
|
114
|
+
"""Manages the guided tour state and event dispatch."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, display: Any, tour: Tour | None = None) -> None:
|
|
117
|
+
self._display = display
|
|
118
|
+
self._tour = tour or Tour(name="empty")
|
|
119
|
+
self._enabled = True
|
|
120
|
+
self._listeners: list[Callable[[str, str | None], None]] = []
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def tour(self) -> Tour:
|
|
124
|
+
return self._tour
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def is_active(self) -> bool:
|
|
128
|
+
return self._enabled and not self._tour.is_complete
|
|
129
|
+
|
|
130
|
+
def disable(self) -> None:
|
|
131
|
+
"""Disable the guide (e.g., for experienced users)."""
|
|
132
|
+
self._enabled = False
|
|
133
|
+
|
|
134
|
+
def add_listener(self, listener: Callable[[str, str | None], None]) -> None:
|
|
135
|
+
"""Add a custom event listener for extensibility."""
|
|
136
|
+
self._listeners.append(listener)
|
|
137
|
+
|
|
138
|
+
def fire(self, event: str, value: str | None = None) -> None:
|
|
139
|
+
"""Fire a guide event. Shows hint if it matches the current step.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
event: Event name (e.g., "command", "cd", "invoke", "connected").
|
|
143
|
+
value: Optional value (e.g., command name, target path).
|
|
144
|
+
"""
|
|
145
|
+
if not self._enabled:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
# Notify custom listeners
|
|
149
|
+
for listener in self._listeners:
|
|
150
|
+
listener(event, value)
|
|
151
|
+
|
|
152
|
+
step = self._tour.current
|
|
153
|
+
if step is None:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# Check if this event matches the current step's trigger
|
|
157
|
+
if step.trigger != event:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
# Check trigger_value if specified
|
|
161
|
+
if step.trigger_value is not None:
|
|
162
|
+
if step.trigger_value.endswith("/*"):
|
|
163
|
+
# Glob match: /services/* matches /services/anything
|
|
164
|
+
prefix = step.trigger_value[:-1]
|
|
165
|
+
if value is None or not value.startswith(prefix):
|
|
166
|
+
return
|
|
167
|
+
elif step.trigger_value != value:
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# Show the hint
|
|
171
|
+
self._display.print()
|
|
172
|
+
self._display.print(f" [dim]{'─' * 50}[/dim]")
|
|
173
|
+
self._display.print(step.message)
|
|
174
|
+
self._display.print(f" [dim]{'─' * 50}[/dim]")
|
|
175
|
+
self._display.print()
|
|
176
|
+
|
|
177
|
+
self._tour.advance()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── Persistence ───────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
_CONFIG_PATH = Path(os.path.expanduser("~/.aster/config.toml"))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def is_first_time() -> bool:
|
|
186
|
+
"""Check if this is the user's first time using the shell."""
|
|
187
|
+
if not _CONFIG_PATH.exists():
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
if __import__("sys").version_info >= (3, 11):
|
|
192
|
+
import tomllib
|
|
193
|
+
else:
|
|
194
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
195
|
+
|
|
196
|
+
with _CONFIG_PATH.open("rb") as f:
|
|
197
|
+
config = tomllib.load(f)
|
|
198
|
+
return config.get("shell", {}).get("first_time", True)
|
|
199
|
+
except Exception:
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def mark_tour_complete() -> None:
|
|
204
|
+
"""Mark the guided tour as complete in the config."""
|
|
205
|
+
_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
|
|
207
|
+
# Read existing config
|
|
208
|
+
existing_lines: list[str] = []
|
|
209
|
+
if _CONFIG_PATH.exists():
|
|
210
|
+
existing_lines = _CONFIG_PATH.read_text().splitlines()
|
|
211
|
+
|
|
212
|
+
# Check if [shell] section exists
|
|
213
|
+
shell_section_idx = None
|
|
214
|
+
first_time_idx = None
|
|
215
|
+
for i, line in enumerate(existing_lines):
|
|
216
|
+
if line.strip() == "[shell]":
|
|
217
|
+
shell_section_idx = i
|
|
218
|
+
if "first_time" in line and shell_section_idx is not None:
|
|
219
|
+
first_time_idx = i
|
|
220
|
+
|
|
221
|
+
if first_time_idx is not None:
|
|
222
|
+
existing_lines[first_time_idx] = "first_time = false"
|
|
223
|
+
elif shell_section_idx is not None:
|
|
224
|
+
existing_lines.insert(shell_section_idx + 1, "first_time = false")
|
|
225
|
+
else:
|
|
226
|
+
existing_lines.append("")
|
|
227
|
+
existing_lines.append("[shell]")
|
|
228
|
+
existing_lines.append("first_time = false")
|
|
229
|
+
|
|
230
|
+
_CONFIG_PATH.write_text("\n".join(existing_lines) + "\n")
|
aster_cli/shell/hooks.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aster_cli.shell.hooks -- Extension hooks for the shell.
|
|
3
|
+
|
|
4
|
+
Provides the hook protocol and registry for plugging in:
|
|
5
|
+
- Input builders (construct RPC payloads from user intent)
|
|
6
|
+
- Output renderers (format RPC responses for display)
|
|
7
|
+
- Session lifecycle events
|
|
8
|
+
|
|
9
|
+
The LLM plugin will implement InputBuilder and OutputRenderer to handle
|
|
10
|
+
complex types conversationally rather than field-by-field.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── Hook protocols ────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class InputBuilder(Protocol):
|
|
25
|
+
"""Builds an RPC request payload from user input and method schema.
|
|
26
|
+
|
|
27
|
+
The default implementation prompts field-by-field. An LLM plugin
|
|
28
|
+
replaces this with conversational input construction:
|
|
29
|
+
|
|
30
|
+
1. Receives the method schema (fields, types, nested types, constraints)
|
|
31
|
+
2. Receives any raw user input (partial key=value pairs, natural language)
|
|
32
|
+
3. Constructs the complete JSON payload, asking follow-up questions if needed
|
|
33
|
+
|
|
34
|
+
For complex nested types, the LLM can:
|
|
35
|
+
- Explain what each field means in context
|
|
36
|
+
- Suggest reasonable defaults
|
|
37
|
+
- Validate constraints before sending
|
|
38
|
+
- Build nested objects conversationally ("What file do you want to get?")
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
async def build_payload(
|
|
42
|
+
self,
|
|
43
|
+
method_schema: MethodSchema,
|
|
44
|
+
user_input: dict[str, Any],
|
|
45
|
+
ask: AskFn,
|
|
46
|
+
) -> dict[str, Any] | None:
|
|
47
|
+
"""Build a complete RPC payload.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
method_schema: Full type information for the method.
|
|
51
|
+
user_input: Any args the user already provided (may be partial/empty).
|
|
52
|
+
ask: Callable to prompt the user for more information.
|
|
53
|
+
Signature: ask(prompt: str) -> str | None (None = cancelled)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Complete payload dict, or None if cancelled.
|
|
57
|
+
"""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@runtime_checkable
|
|
62
|
+
class OutputRenderer(Protocol):
|
|
63
|
+
"""Renders an RPC response for display.
|
|
64
|
+
|
|
65
|
+
The default implementation dumps JSON. An LLM plugin replaces this
|
|
66
|
+
with intelligent rendering:
|
|
67
|
+
|
|
68
|
+
1. Receives the response data and its type schema
|
|
69
|
+
2. Decides the best presentation:
|
|
70
|
+
- Simple scalar → inline display
|
|
71
|
+
- List of records → table
|
|
72
|
+
- Nested object → tree or summarized view
|
|
73
|
+
- Large payload → paginated or summarized
|
|
74
|
+
- Error/status → highlighted with explanation
|
|
75
|
+
3. Can explain what the response means in context
|
|
76
|
+
|
|
77
|
+
The renderer also receives the display object so it can use
|
|
78
|
+
rich tables, trees, panels, etc.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
async def render_response(
|
|
82
|
+
self,
|
|
83
|
+
method_schema: MethodSchema,
|
|
84
|
+
result: Any,
|
|
85
|
+
display: Any,
|
|
86
|
+
) -> bool:
|
|
87
|
+
"""Render an RPC response.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
method_schema: Full type information for the method.
|
|
91
|
+
result: The response data (already deserialized).
|
|
92
|
+
display: The Display instance for output.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
True if the response was rendered (suppresses default JSON dump).
|
|
96
|
+
False to fall through to default rendering.
|
|
97
|
+
"""
|
|
98
|
+
...
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@runtime_checkable
|
|
102
|
+
class SessionHook(Protocol):
|
|
103
|
+
"""Lifecycle hook for session-scoped services.
|
|
104
|
+
|
|
105
|
+
Called when entering/exiting a session subshell.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
async def on_session_start(self, service_name: str, ctx: Any) -> None: ...
|
|
109
|
+
async def on_session_end(self, service_name: str, ctx: Any) -> None: ...
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── Data types ────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class FieldSchema:
|
|
117
|
+
"""Schema for a single field in a request/response type."""
|
|
118
|
+
|
|
119
|
+
name: str
|
|
120
|
+
type_name: str # "str", "int", "list[str]", "MyNestedType", etc.
|
|
121
|
+
required: bool = True
|
|
122
|
+
default: Any = None
|
|
123
|
+
description: str = ""
|
|
124
|
+
nested_fields: list[FieldSchema] | None = None # for complex types
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class MethodSchema:
|
|
129
|
+
"""Full schema for a method -- everything a hook needs to build input or render output."""
|
|
130
|
+
|
|
131
|
+
service_name: str
|
|
132
|
+
method_name: str
|
|
133
|
+
pattern: str # "unary", "server_stream", "client_stream", "bidi_stream"
|
|
134
|
+
request_type: str = ""
|
|
135
|
+
response_type: str = ""
|
|
136
|
+
request_fields: list[FieldSchema] | None = None
|
|
137
|
+
response_fields: list[FieldSchema] | None = None
|
|
138
|
+
timeout: float | None = None
|
|
139
|
+
description: str = ""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# Type alias for the ask function
|
|
143
|
+
AskFn = Any # Callable[[str], Awaitable[str | None]] -- avoid complex type for 3.9
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── Hook registry ─────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class HookRegistry:
|
|
150
|
+
"""Central registry for shell extension hooks."""
|
|
151
|
+
|
|
152
|
+
def __init__(self) -> None:
|
|
153
|
+
self._input_builders: list[InputBuilder] = []
|
|
154
|
+
self._output_renderers: list[OutputRenderer] = []
|
|
155
|
+
self._session_hooks: list[SessionHook] = []
|
|
156
|
+
|
|
157
|
+
def register_input_builder(self, builder: InputBuilder) -> None:
|
|
158
|
+
"""Register an input builder (e.g., LLM-powered)."""
|
|
159
|
+
self._input_builders.append(builder)
|
|
160
|
+
|
|
161
|
+
def register_output_renderer(self, renderer: OutputRenderer) -> None:
|
|
162
|
+
"""Register an output renderer (e.g., LLM-powered)."""
|
|
163
|
+
self._output_renderers.append(renderer)
|
|
164
|
+
|
|
165
|
+
def register_session_hook(self, hook: SessionHook) -> None:
|
|
166
|
+
"""Register a session lifecycle hook."""
|
|
167
|
+
self._session_hooks.append(hook)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def input_builder(self) -> InputBuilder | None:
|
|
171
|
+
"""The active input builder (last registered wins)."""
|
|
172
|
+
return self._input_builders[-1] if self._input_builders else None
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def output_renderer(self) -> OutputRenderer | None:
|
|
176
|
+
"""The active output renderer (last registered wins)."""
|
|
177
|
+
return self._output_renderers[-1] if self._output_renderers else None
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def session_hooks(self) -> list[SessionHook]:
|
|
181
|
+
"""All registered session hooks."""
|
|
182
|
+
return list(self._session_hooks)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── Default implementations ──────────────────────────────────────��────────────
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class DefaultInputBuilder:
|
|
189
|
+
"""Field-by-field interactive prompting (the built-in fallback)."""
|
|
190
|
+
|
|
191
|
+
async def build_payload(
|
|
192
|
+
self,
|
|
193
|
+
method_schema: MethodSchema,
|
|
194
|
+
user_input: dict[str, Any],
|
|
195
|
+
ask: AskFn,
|
|
196
|
+
) -> dict[str, Any] | None:
|
|
197
|
+
if not method_schema.request_fields:
|
|
198
|
+
return user_input or {}
|
|
199
|
+
|
|
200
|
+
result = dict(user_input) # start with what user already provided
|
|
201
|
+
|
|
202
|
+
for f in method_schema.request_fields:
|
|
203
|
+
if f.name in result:
|
|
204
|
+
continue # already provided
|
|
205
|
+
|
|
206
|
+
prompt = f" ▸ {f.name}"
|
|
207
|
+
if f.type_name:
|
|
208
|
+
prompt += f" ({f.type_name})"
|
|
209
|
+
if f.default is not None:
|
|
210
|
+
prompt += f" [{f.default}]"
|
|
211
|
+
prompt += ": "
|
|
212
|
+
|
|
213
|
+
value = await ask(prompt)
|
|
214
|
+
if value is None:
|
|
215
|
+
return None # cancelled
|
|
216
|
+
|
|
217
|
+
value = value.strip()
|
|
218
|
+
if not value and f.default is not None:
|
|
219
|
+
result[f.name] = f.default
|
|
220
|
+
elif not value and not f.required:
|
|
221
|
+
continue
|
|
222
|
+
elif not value:
|
|
223
|
+
return None # required field missing
|
|
224
|
+
else:
|
|
225
|
+
# Try JSON parse
|
|
226
|
+
import json
|
|
227
|
+
try:
|
|
228
|
+
result[f.name] = json.loads(value)
|
|
229
|
+
except (json.JSONDecodeError, ValueError):
|
|
230
|
+
result[f.name] = value
|
|
231
|
+
|
|
232
|
+
return result
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class DefaultOutputRenderer:
|
|
236
|
+
"""JSON dump with timing (the built-in fallback)."""
|
|
237
|
+
|
|
238
|
+
async def render_response(
|
|
239
|
+
self,
|
|
240
|
+
method_schema: MethodSchema,
|
|
241
|
+
result: Any,
|
|
242
|
+
display: Any,
|
|
243
|
+
) -> bool:
|
|
244
|
+
# Return False to use default rendering in invoker
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ── Singleton ─────────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
_registry = HookRegistry()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def get_hook_registry() -> HookRegistry:
|
|
254
|
+
"""Get the global hook registry."""
|
|
255
|
+
return _registry
|