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.
@@ -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")
@@ -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