mash-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mash_cli-0.1.0/PKG-INFO +25 -0
- mash_cli-0.1.0/README.md +13 -0
- mash_cli-0.1.0/pyproject.toml +24 -0
- mash_cli-0.1.0/setup.cfg +4 -0
- mash_cli-0.1.0/src/mash_cli/__init__.py +7 -0
- mash_cli-0.1.0/src/mash_cli/chain_renderer.py +316 -0
- mash_cli-0.1.0/src/mash_cli/commands.py +198 -0
- mash_cli-0.1.0/src/mash_cli/default_commands.py +193 -0
- mash_cli-0.1.0/src/mash_cli/main.py +39 -0
- mash_cli-0.1.0/src/mash_cli/render.py +123 -0
- mash_cli-0.1.0/src/mash_cli/repl.py +124 -0
- mash_cli-0.1.0/src/mash_cli/shell.py +163 -0
- mash_cli-0.1.0/src/mash_cli/types.py +19 -0
- mash_cli-0.1.0/src/mash_cli.egg-info/PKG-INFO +25 -0
- mash_cli-0.1.0/src/mash_cli.egg-info/SOURCES.txt +17 -0
- mash_cli-0.1.0/src/mash_cli.egg-info/dependency_links.txt +1 -0
- mash_cli-0.1.0/src/mash_cli.egg-info/entry_points.txt +2 -0
- mash_cli-0.1.0/src/mash_cli.egg-info/requires.txt +3 -0
- mash_cli-0.1.0/src/mash_cli.egg-info/top_level.txt +1 -0
mash_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mash-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive CLI shell package for Mash applications
|
|
5
|
+
Author: imsid
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: mashpy>=0.1.2
|
|
10
|
+
Requires-Dist: prompt_toolkit>=3.0.36
|
|
11
|
+
Requires-Dist: rich>=13.7.1
|
|
12
|
+
|
|
13
|
+
# mash-cli
|
|
14
|
+
|
|
15
|
+
Interactive CLI package for Mash applications.
|
|
16
|
+
|
|
17
|
+
It provides:
|
|
18
|
+
|
|
19
|
+
- `mash` console command
|
|
20
|
+
- `mash_cli.CLIAppShell` and related command primitives for app shells
|
|
21
|
+
|
|
22
|
+
Install options:
|
|
23
|
+
|
|
24
|
+
- `pip install mash-cli`
|
|
25
|
+
- `pip install "mashpy[cli]"`
|
mash_cli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# mash-cli
|
|
2
|
+
|
|
3
|
+
Interactive CLI package for Mash applications.
|
|
4
|
+
|
|
5
|
+
It provides:
|
|
6
|
+
|
|
7
|
+
- `mash` console command
|
|
8
|
+
- `mash_cli.CLIAppShell` and related command primitives for app shells
|
|
9
|
+
|
|
10
|
+
Install options:
|
|
11
|
+
|
|
12
|
+
- `pip install mash-cli`
|
|
13
|
+
- `pip install "mashpy[cli]"`
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mash-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Interactive CLI shell package for Mash applications"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = {text = "Proprietary"}
|
|
8
|
+
authors = [{ name = "imsid" }]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"mashpy>=0.1.2",
|
|
11
|
+
"prompt_toolkit>=3.0.36",
|
|
12
|
+
"rich>=13.7.1",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
mash = "mash_cli.main:main"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["setuptools>=68", "wheel"]
|
|
20
|
+
build-backend = "setuptools.build_meta"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
include = ["mash_cli*"]
|
mash_cli-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Real-time chain of thought renderer for agent execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.live import Live
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from mash.logging.events import AgentTraceEvent, LLMEvent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChainOfThoughtRenderer:
|
|
16
|
+
"""Renders agent's chain of thought in real-time."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, console: Optional[Console] = None) -> None:
|
|
19
|
+
"""Initialize renderer.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
console: Rich console instance. Creates new one if not provided.
|
|
23
|
+
"""
|
|
24
|
+
self._console = console or Console()
|
|
25
|
+
self._current_trace_id: Optional[str] = None
|
|
26
|
+
self._current_step: int = 0
|
|
27
|
+
self._steps: List[Dict[str, Any]] = []
|
|
28
|
+
self._live: Optional[Live] = None
|
|
29
|
+
self._enabled = True
|
|
30
|
+
|
|
31
|
+
def enable(self) -> None:
|
|
32
|
+
"""Enable rendering."""
|
|
33
|
+
self._enabled = True
|
|
34
|
+
|
|
35
|
+
def disable(self) -> None:
|
|
36
|
+
"""Disable rendering."""
|
|
37
|
+
self._enabled = False
|
|
38
|
+
|
|
39
|
+
def start_trace(self, trace_id: Optional[str]) -> None:
|
|
40
|
+
"""Start a new execution trace.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
trace_id: Unique trace identifier.
|
|
44
|
+
"""
|
|
45
|
+
if not self._enabled:
|
|
46
|
+
return
|
|
47
|
+
if not trace_id:
|
|
48
|
+
return
|
|
49
|
+
self._current_trace_id = trace_id
|
|
50
|
+
self._current_step = 0
|
|
51
|
+
self._steps = []
|
|
52
|
+
self._console.print("\n[bold cyan]Agent Execution Started[/bold cyan]")
|
|
53
|
+
|
|
54
|
+
def on_think_complete(self, event: AgentTraceEvent) -> None:
|
|
55
|
+
"""Handle think complete event.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
event: Agent trace event.
|
|
59
|
+
"""
|
|
60
|
+
if not self._enabled:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
# Start new trace if needed
|
|
64
|
+
if event.trace_id != self._current_trace_id:
|
|
65
|
+
self.start_trace(event.trace_id)
|
|
66
|
+
|
|
67
|
+
# Extract tool_calls_detail from payload if available
|
|
68
|
+
tool_calls_detail = None
|
|
69
|
+
assistant_text = None
|
|
70
|
+
if hasattr(event, "payload") and event.payload:
|
|
71
|
+
tool_calls_detail = event.payload.get("tool_calls_detail")
|
|
72
|
+
assistant_text = event.payload.get("assistant_text")
|
|
73
|
+
|
|
74
|
+
step_info = {
|
|
75
|
+
"step": event.step_id,
|
|
76
|
+
"action_type": event.action_type,
|
|
77
|
+
"tool_calls": event.tool_calls,
|
|
78
|
+
"tool_calls_detail": tool_calls_detail,
|
|
79
|
+
"assistant_text": assistant_text,
|
|
80
|
+
"token_usage": event.token_usage,
|
|
81
|
+
"think_duration": event.duration_ms,
|
|
82
|
+
}
|
|
83
|
+
self._steps.append(step_info)
|
|
84
|
+
|
|
85
|
+
# Render thinking
|
|
86
|
+
self._render_think(step_info)
|
|
87
|
+
|
|
88
|
+
def on_act_complete(self, event: AgentTraceEvent) -> None:
|
|
89
|
+
"""Handle act complete event.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
event: Agent trace event.
|
|
93
|
+
"""
|
|
94
|
+
if not self._enabled or not self._steps:
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
# Update last step with act duration
|
|
98
|
+
self._steps[-1]["act_duration"] = event.duration_ms
|
|
99
|
+
self._render_act(self._steps[-1])
|
|
100
|
+
|
|
101
|
+
def on_step_complete(self, event: AgentTraceEvent) -> None:
|
|
102
|
+
"""Handle step complete event.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
event: Agent trace event.
|
|
106
|
+
"""
|
|
107
|
+
if not self._enabled or not self._steps:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# Update last step with total duration
|
|
111
|
+
self._steps[-1]["total_duration"] = event.duration_ms
|
|
112
|
+
self._render_step_complete(self._steps[-1])
|
|
113
|
+
self._current_step += 1
|
|
114
|
+
|
|
115
|
+
def on_llm_request_start(self) -> None:
|
|
116
|
+
"""Handle LLM request start."""
|
|
117
|
+
if not self._enabled:
|
|
118
|
+
return
|
|
119
|
+
# Could show a spinner here if desired
|
|
120
|
+
|
|
121
|
+
def on_llm_request_complete(self, event: LLMEvent) -> None:
|
|
122
|
+
"""Handle LLM request complete.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
event: LLM event.
|
|
126
|
+
"""
|
|
127
|
+
# Events are already captured in think_complete via token_usage
|
|
128
|
+
|
|
129
|
+
def finish_trace(self) -> None:
|
|
130
|
+
"""Finish the current trace."""
|
|
131
|
+
if not self._enabled:
|
|
132
|
+
return
|
|
133
|
+
if self._steps:
|
|
134
|
+
self._render_summary()
|
|
135
|
+
self._current_trace_id = None
|
|
136
|
+
self._steps = []
|
|
137
|
+
|
|
138
|
+
def _render_think(self, step: Dict[str, Any]) -> None:
|
|
139
|
+
"""Render thinking phase.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
step: Step information.
|
|
143
|
+
"""
|
|
144
|
+
action_type = step.get("action_type", "unknown")
|
|
145
|
+
tool_calls = step.get("tool_calls") or []
|
|
146
|
+
tool_calls_detail = step.get("tool_calls_detail") or []
|
|
147
|
+
assistant_text = step.get("assistant_text")
|
|
148
|
+
token_usage = step.get("token_usage") or {}
|
|
149
|
+
think_duration = step.get("think_duration", 0)
|
|
150
|
+
|
|
151
|
+
# Build step description
|
|
152
|
+
if action_type == "tool_call" and tool_calls:
|
|
153
|
+
tools_str = ", ".join(f"[yellow]{t}[/yellow]" for t in tool_calls)
|
|
154
|
+
desc = f"Calling tools: {tools_str}"
|
|
155
|
+
elif action_type == "response":
|
|
156
|
+
desc = "[green]Generating response[/green]"
|
|
157
|
+
elif action_type == "finish":
|
|
158
|
+
desc = "[blue]Finishing execution[/blue]"
|
|
159
|
+
else:
|
|
160
|
+
desc = f"Action: {action_type}"
|
|
161
|
+
|
|
162
|
+
# Show tokens if available
|
|
163
|
+
token_str = ""
|
|
164
|
+
if token_usage:
|
|
165
|
+
input_tok = token_usage.get("input", 0)
|
|
166
|
+
output_tok = token_usage.get("output", 0)
|
|
167
|
+
token_str = f" [dim]({input_tok}+{output_tok} tokens)[/dim]"
|
|
168
|
+
|
|
169
|
+
self._console.print(
|
|
170
|
+
f" [cyan]→[/cyan] Step {self._current_step + 1}: {desc}{token_str} "
|
|
171
|
+
f"[dim]{think_duration}ms[/dim]"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if assistant_text:
|
|
175
|
+
self._console.print(f" [dim]assistant: {assistant_text}[/dim]")
|
|
176
|
+
|
|
177
|
+
# Show tool commands/arguments if available
|
|
178
|
+
if tool_calls_detail:
|
|
179
|
+
for tool_call in tool_calls_detail:
|
|
180
|
+
tool_name = tool_call.get("name", "unknown")
|
|
181
|
+
tool_args = tool_call.get("arguments", {})
|
|
182
|
+
|
|
183
|
+
# Special handling for bash tool to show the command
|
|
184
|
+
if tool_name == "bash" and "command" in tool_args:
|
|
185
|
+
command = tool_args["command"]
|
|
186
|
+
# Truncate long commands
|
|
187
|
+
if len(command) > 80:
|
|
188
|
+
command = command[:77] + "..."
|
|
189
|
+
self._console.print(f" [dim]$ {command}[/dim]")
|
|
190
|
+
# For other tools, show arguments more generically
|
|
191
|
+
elif tool_args:
|
|
192
|
+
# Show first few keys/values
|
|
193
|
+
args_preview = []
|
|
194
|
+
for key, value in list(tool_args.items())[:2]:
|
|
195
|
+
if isinstance(value, str) and len(value) > 40:
|
|
196
|
+
value = value[:37] + "..."
|
|
197
|
+
args_preview.append(f"{key}={value}")
|
|
198
|
+
if len(tool_args) > 2:
|
|
199
|
+
args_preview.append(f"+{len(tool_args) - 2} more")
|
|
200
|
+
args_str = ", ".join(args_preview)
|
|
201
|
+
self._console.print(f" [dim]{tool_name}({args_str})[/dim]")
|
|
202
|
+
|
|
203
|
+
def _render_act(self, step: Dict[str, Any]) -> None:
|
|
204
|
+
"""Render action phase.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
step: Step information.
|
|
208
|
+
"""
|
|
209
|
+
act_duration = step.get("act_duration", 0)
|
|
210
|
+
tool_calls = step.get("tool_calls") or []
|
|
211
|
+
|
|
212
|
+
if tool_calls:
|
|
213
|
+
self._console.print(
|
|
214
|
+
f" [dim]✓ Executed {len(tool_calls)} tool(s) in {act_duration}ms[/dim]"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _render_step_complete(self, step: Dict[str, Any]) -> None:
|
|
218
|
+
"""Render step completion.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
step: Step information.
|
|
222
|
+
"""
|
|
223
|
+
# Just a blank line for spacing
|
|
224
|
+
# self._console.print()
|
|
225
|
+
|
|
226
|
+
def _render_summary(self) -> None:
|
|
227
|
+
"""Render execution summary."""
|
|
228
|
+
if not self._steps:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
total_steps = len(self._steps)
|
|
232
|
+
total_duration = sum(s.get("total_duration", 0) for s in self._steps)
|
|
233
|
+
total_tokens = sum(
|
|
234
|
+
s.get("token_usage", {}).get("input", 0)
|
|
235
|
+
+ s.get("token_usage", {}).get("output", 0)
|
|
236
|
+
for s in self._steps
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Count tool calls
|
|
240
|
+
tool_calls = []
|
|
241
|
+
for step in self._steps:
|
|
242
|
+
if step.get("tool_calls"):
|
|
243
|
+
tool_calls.extend(step["tool_calls"])
|
|
244
|
+
|
|
245
|
+
summary = Text()
|
|
246
|
+
summary.append("\nAgent Execution Complete: ", style="bold green")
|
|
247
|
+
summary.append(f"{total_steps} steps, ", style="dim")
|
|
248
|
+
summary.append(f"{len(tool_calls)} tools, ", style="dim")
|
|
249
|
+
summary.append(f"{total_tokens:,} tokens, ", style="dim")
|
|
250
|
+
summary.append(f"{total_duration:,}ms", style="dim")
|
|
251
|
+
|
|
252
|
+
self._console.print(summary)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class CompactChainRenderer:
|
|
256
|
+
"""Compact single-line renderer for agent execution."""
|
|
257
|
+
|
|
258
|
+
def __init__(self, console: Optional[Console] = None) -> None:
|
|
259
|
+
"""Initialize compact renderer.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
console: Rich console instance.
|
|
263
|
+
"""
|
|
264
|
+
self._console = console or Console()
|
|
265
|
+
self._enabled = True
|
|
266
|
+
self._current_step = 0
|
|
267
|
+
|
|
268
|
+
def enable(self) -> None:
|
|
269
|
+
"""Enable rendering."""
|
|
270
|
+
self._enabled = True
|
|
271
|
+
|
|
272
|
+
def disable(self) -> None:
|
|
273
|
+
"""Disable rendering."""
|
|
274
|
+
self._enabled = False
|
|
275
|
+
|
|
276
|
+
def start_trace(self, trace_id: Optional[str]) -> None:
|
|
277
|
+
"""Start trace."""
|
|
278
|
+
if not self._enabled:
|
|
279
|
+
return
|
|
280
|
+
if not trace_id:
|
|
281
|
+
return
|
|
282
|
+
self._current_step = 0
|
|
283
|
+
self._console.print("[dim]Thinking...[/dim]", end=" ")
|
|
284
|
+
|
|
285
|
+
def on_think_complete(self, event: AgentTraceEvent) -> None:
|
|
286
|
+
"""Handle think complete."""
|
|
287
|
+
if not self._enabled:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
action_type = event.action_type
|
|
291
|
+
tool_calls = event.tool_calls or []
|
|
292
|
+
|
|
293
|
+
if action_type == "tool_call" and tool_calls:
|
|
294
|
+
# Show tool icons
|
|
295
|
+
for _ in tool_calls:
|
|
296
|
+
self._console.print("[yellow]⚡[/yellow]", end="")
|
|
297
|
+
elif action_type == "response":
|
|
298
|
+
self._console.print("[green]💬[/green]", end="")
|
|
299
|
+
elif action_type == "finish":
|
|
300
|
+
self._console.print("[blue]✓[/blue]", end="")
|
|
301
|
+
|
|
302
|
+
def on_act_complete(self) -> None:
|
|
303
|
+
"""Handle act complete."""
|
|
304
|
+
# Compact mode doesn't show act separately
|
|
305
|
+
|
|
306
|
+
def on_step_complete(self) -> None:
|
|
307
|
+
"""Handle step complete."""
|
|
308
|
+
if not self._enabled:
|
|
309
|
+
return
|
|
310
|
+
self._current_step += 1
|
|
311
|
+
|
|
312
|
+
def finish_trace(self) -> None:
|
|
313
|
+
"""Finish trace."""
|
|
314
|
+
if not self._enabled:
|
|
315
|
+
return
|
|
316
|
+
self._console.print() # New line after all steps
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Command system for CLI applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Protocol
|
|
8
|
+
|
|
9
|
+
from mash.logging.events import CommandEvent
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .types import CLIContext
|
|
13
|
+
|
|
14
|
+
CommandHandler = Callable[["CLIContext", List[str]], None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SupportsCommandEventLogger(Protocol):
|
|
18
|
+
"""Minimal command-event logger interface."""
|
|
19
|
+
|
|
20
|
+
def emit(self, event: CommandEvent) -> None:
|
|
21
|
+
"""Emit one command event."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Command:
|
|
26
|
+
"""Command definition."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
help: str
|
|
30
|
+
handler: CommandHandler
|
|
31
|
+
aliases: tuple[str, ...] = ()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CommandRegistry:
|
|
35
|
+
"""Registry for managing commands."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
app_id: str,
|
|
40
|
+
event_logger: Optional[SupportsCommandEventLogger],
|
|
41
|
+
session_id: str,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Initialize command registry.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
event_logger: Eevent logger for logging command execution.
|
|
47
|
+
session_id: session ID for event logging.
|
|
48
|
+
app_id: app ID for event logging.
|
|
49
|
+
"""
|
|
50
|
+
self._commands: Dict[str, Command] = {}
|
|
51
|
+
self._lookup: Dict[str, Command] = {}
|
|
52
|
+
self._event_logger = event_logger
|
|
53
|
+
self._session_id = session_id
|
|
54
|
+
self._app_id = app_id
|
|
55
|
+
|
|
56
|
+
def register(self, command: Command) -> None:
|
|
57
|
+
"""Register a command.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
command: Command to register.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If command name is empty or already registered.
|
|
64
|
+
"""
|
|
65
|
+
name = self._normalize(command.name)
|
|
66
|
+
if not name:
|
|
67
|
+
raise ValueError("Command name cannot be empty")
|
|
68
|
+
|
|
69
|
+
if name in self._commands:
|
|
70
|
+
raise ValueError(f"Command '{name}' is already registered")
|
|
71
|
+
|
|
72
|
+
self._commands[name] = command
|
|
73
|
+
self._lookup[name] = command
|
|
74
|
+
|
|
75
|
+
# Register aliases
|
|
76
|
+
for alias in command.aliases:
|
|
77
|
+
alias_key = self._normalize(alias)
|
|
78
|
+
if alias_key:
|
|
79
|
+
self._lookup[alias_key] = command
|
|
80
|
+
|
|
81
|
+
def unregister(self, name: str) -> None:
|
|
82
|
+
"""Unregister a command.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
name: Command name to unregister.
|
|
86
|
+
"""
|
|
87
|
+
name = self._normalize(name)
|
|
88
|
+
self._commands.pop(name, None)
|
|
89
|
+
# Remove from lookup
|
|
90
|
+
to_remove = [k for k, v in self._lookup.items() if v.name == name]
|
|
91
|
+
for k in to_remove:
|
|
92
|
+
self._lookup.pop(k, None)
|
|
93
|
+
|
|
94
|
+
def get(self, name: str) -> Command | None:
|
|
95
|
+
"""Get a command by name or alias.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
name: Command name or alias.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Command if found, None otherwise.
|
|
102
|
+
"""
|
|
103
|
+
return self._lookup.get(self._normalize(name))
|
|
104
|
+
|
|
105
|
+
def list_commands(self) -> List[Command]:
|
|
106
|
+
"""List all registered commands.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
List of commands sorted by name.
|
|
110
|
+
"""
|
|
111
|
+
return sorted(self._commands.values(), key=lambda c: c.name)
|
|
112
|
+
|
|
113
|
+
def execute(self, ctx: CLIContext, line: str) -> bool:
|
|
114
|
+
"""Execute a command if the line is a command.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
ctx: CLI context.
|
|
118
|
+
line: Input line.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
True if line was a command, False otherwise.
|
|
122
|
+
"""
|
|
123
|
+
line = line.strip()
|
|
124
|
+
if not line or not line.startswith("/"):
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
# Parse command
|
|
128
|
+
payload = line[1:].strip()
|
|
129
|
+
if not payload:
|
|
130
|
+
ctx.renderer.warn("Unknown command. Try /help.")
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
parts = payload.split()
|
|
134
|
+
cmd_name = parts[0]
|
|
135
|
+
args = parts[1:]
|
|
136
|
+
|
|
137
|
+
# Find command
|
|
138
|
+
command = self.get(cmd_name)
|
|
139
|
+
if not command:
|
|
140
|
+
ctx.renderer.warn(f"Unknown command: /{cmd_name}. Try /help.")
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
# Execute command with logging
|
|
144
|
+
start_time = time.time()
|
|
145
|
+
command_name = f"/{command.name}"
|
|
146
|
+
args_str = " ".join(args)
|
|
147
|
+
|
|
148
|
+
# Log command start
|
|
149
|
+
if self._event_logger:
|
|
150
|
+
|
|
151
|
+
self._event_logger.emit(
|
|
152
|
+
CommandEvent(
|
|
153
|
+
event_type="command.start",
|
|
154
|
+
app_id=self._app_id,
|
|
155
|
+
session_id=self._session_id,
|
|
156
|
+
command_name=command_name,
|
|
157
|
+
args=args_str,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
command.handler(ctx, args)
|
|
163
|
+
|
|
164
|
+
# Log command completion
|
|
165
|
+
if self._event_logger:
|
|
166
|
+
|
|
167
|
+
self._event_logger.emit(
|
|
168
|
+
CommandEvent(
|
|
169
|
+
event_type="command.complete",
|
|
170
|
+
app_id=self._app_id,
|
|
171
|
+
session_id=self._session_id,
|
|
172
|
+
command_name=command_name,
|
|
173
|
+
duration_ms=int((time.time() - start_time) * 1000),
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
ctx.renderer.error(f"Command failed: {str(e)}")
|
|
178
|
+
|
|
179
|
+
# Log command error
|
|
180
|
+
if self._event_logger:
|
|
181
|
+
|
|
182
|
+
self._event_logger.emit(
|
|
183
|
+
CommandEvent(
|
|
184
|
+
event_type="command.error",
|
|
185
|
+
app_id=self._app_id,
|
|
186
|
+
session_id=self._session_id,
|
|
187
|
+
command_name=command_name,
|
|
188
|
+
error=str(e),
|
|
189
|
+
duration_ms=int((time.time() - start_time) * 1000),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _normalize(name: str) -> str:
|
|
197
|
+
"""Normalize command name."""
|
|
198
|
+
return name.lower().strip()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Default slash commands for CLI shells."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from .commands import Command
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_default_commands(shell) -> None:
|
|
10
|
+
"""Register built-in commands for a CLI shell."""
|
|
11
|
+
|
|
12
|
+
def help_command(ctx, _args: list[str]) -> None:
|
|
13
|
+
commands = shell.command_registry.list_commands()
|
|
14
|
+
if not commands:
|
|
15
|
+
ctx.renderer.info("No commands available.")
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
ctx.renderer.info("Available commands:")
|
|
19
|
+
for command in commands:
|
|
20
|
+
aliases = f" (aliases: {', '.join(command.aliases)})" if command.aliases else ""
|
|
21
|
+
ctx.renderer.print(f" /{command.name}{aliases} - {command.help}")
|
|
22
|
+
|
|
23
|
+
def exit_command(_ctx, _args: list[str]) -> None:
|
|
24
|
+
raise SystemExit(0)
|
|
25
|
+
|
|
26
|
+
def clear_command(ctx, _args: list[str]) -> None:
|
|
27
|
+
ctx.renderer.clear()
|
|
28
|
+
|
|
29
|
+
def session_command(ctx, _args: list[str]) -> None:
|
|
30
|
+
runtime = ctx.runtime
|
|
31
|
+
ctx.renderer.info(f"App: {ctx.app_id}")
|
|
32
|
+
ctx.renderer.info(f"Session ID: {ctx.session_id}")
|
|
33
|
+
ctx.renderer.info(f"Primary agent: {runtime.app_id}")
|
|
34
|
+
subagent_ids = runtime.get_subagent_ids()
|
|
35
|
+
if subagent_ids:
|
|
36
|
+
ctx.renderer.info(f"Subagents: {', '.join(subagent_ids)}")
|
|
37
|
+
ctx.renderer.info(f"Model: {runtime.get_model()}")
|
|
38
|
+
ctx.renderer.info(f"Max steps: {runtime.get_max_steps()}")
|
|
39
|
+
ctx.renderer.info(
|
|
40
|
+
f"Session tokens: {runtime.get_session_total_tokens(ctx.session_id)}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def prefs_command(ctx, args: list[str]) -> None:
|
|
44
|
+
runtime = ctx.runtime
|
|
45
|
+
if not args:
|
|
46
|
+
prefs = runtime.get_latest_preferences()
|
|
47
|
+
if prefs:
|
|
48
|
+
ctx.renderer.info("Current preferences:")
|
|
49
|
+
ctx.renderer.print(json.dumps(prefs, indent=2))
|
|
50
|
+
else:
|
|
51
|
+
ctx.renderer.warn("No preferences set.")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
subcommand = args[0].lower()
|
|
55
|
+
if subcommand == "set":
|
|
56
|
+
if len(args) < 2:
|
|
57
|
+
ctx.renderer.error("Usage: /prefs set <json>")
|
|
58
|
+
return
|
|
59
|
+
try:
|
|
60
|
+
prefs = json.loads(" ".join(args[1:]))
|
|
61
|
+
if not isinstance(prefs, dict):
|
|
62
|
+
ctx.renderer.error("Preferences must be a JSON object")
|
|
63
|
+
return
|
|
64
|
+
runtime.set_preferences(ctx.session_id, prefs)
|
|
65
|
+
ctx.renderer.info("Preferences saved successfully.")
|
|
66
|
+
except json.JSONDecodeError as exc:
|
|
67
|
+
ctx.renderer.error(f"Invalid JSON: {exc}")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
if subcommand == "clear":
|
|
71
|
+
runtime.set_preferences(ctx.session_id, {})
|
|
72
|
+
ctx.renderer.info("Preferences cleared.")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
ctx.renderer.error(f"Unknown subcommand: {subcommand}")
|
|
76
|
+
ctx.renderer.info("Usage: /prefs [set <json> | clear]")
|
|
77
|
+
|
|
78
|
+
def app_data_command(ctx, args: list[str]) -> None:
|
|
79
|
+
runtime = ctx.runtime
|
|
80
|
+
if not args:
|
|
81
|
+
args = ["list"]
|
|
82
|
+
|
|
83
|
+
subcommand = args[0].lower()
|
|
84
|
+
if subcommand == "list":
|
|
85
|
+
data = runtime.list_app_data(ctx.session_id)
|
|
86
|
+
if data:
|
|
87
|
+
ctx.renderer.info(f"App data ({len(data)} entries):")
|
|
88
|
+
for entry in data:
|
|
89
|
+
ctx.renderer.print(f" {entry['key']}: {json.dumps(entry['value'])}")
|
|
90
|
+
else:
|
|
91
|
+
ctx.renderer.warn("No app data stored.")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if subcommand == "get":
|
|
95
|
+
if len(args) < 2:
|
|
96
|
+
ctx.renderer.error("Usage: /app_data get <key>")
|
|
97
|
+
return
|
|
98
|
+
key = args[1]
|
|
99
|
+
value = runtime.get_app_data(ctx.session_id, key)
|
|
100
|
+
if value is not None:
|
|
101
|
+
ctx.renderer.info(f"Value for '{key}':")
|
|
102
|
+
ctx.renderer.print(json.dumps(value, indent=2))
|
|
103
|
+
else:
|
|
104
|
+
ctx.renderer.warn(f"No data found for key: {key}")
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
if subcommand == "set":
|
|
108
|
+
if len(args) < 3:
|
|
109
|
+
ctx.renderer.error("Usage: /app_data set <key> <json>")
|
|
110
|
+
return
|
|
111
|
+
key = args[1]
|
|
112
|
+
try:
|
|
113
|
+
value = json.loads(" ".join(args[2:]))
|
|
114
|
+
runtime.set_app_data(ctx.session_id, key, value)
|
|
115
|
+
ctx.renderer.info(f"Data stored for key: {key}")
|
|
116
|
+
except json.JSONDecodeError as exc:
|
|
117
|
+
ctx.renderer.error(f"Invalid JSON: {exc}")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if subcommand == "delete":
|
|
121
|
+
if len(args) < 2:
|
|
122
|
+
ctx.renderer.error("Usage: /app_data delete <key>")
|
|
123
|
+
return
|
|
124
|
+
key = args[1]
|
|
125
|
+
deleted = runtime.delete_app_data(ctx.session_id, key)
|
|
126
|
+
if deleted:
|
|
127
|
+
ctx.renderer.info(f"Data deleted for key: {key}")
|
|
128
|
+
else:
|
|
129
|
+
ctx.renderer.warn(f"No data found for key: {key}")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
ctx.renderer.error(f"Unknown subcommand: {subcommand}")
|
|
133
|
+
ctx.renderer.info(
|
|
134
|
+
"Usage: /app_data [list | get <key> | set <key> <json> | delete <key>]"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def history_command(ctx, args: list[str]) -> None:
|
|
138
|
+
limit = None
|
|
139
|
+
if args:
|
|
140
|
+
try:
|
|
141
|
+
limit = int(args[0])
|
|
142
|
+
except ValueError:
|
|
143
|
+
ctx.renderer.error("Limit must be a number")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
turns = ctx.runtime.get_history_turns(ctx.session_id, limit=limit)
|
|
147
|
+
if not turns:
|
|
148
|
+
ctx.renderer.warn("No conversation history.")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
ctx.renderer.info(f"Conversation history ({len(turns)} turns):")
|
|
152
|
+
for index, turn in enumerate(turns, 1):
|
|
153
|
+
ctx.renderer.print(f"\n--- Turn {index} ---")
|
|
154
|
+
ctx.renderer.print(f"User: {turn['user_message']}")
|
|
155
|
+
ctx.renderer.print(f"Agent: {turn['agent_response']}")
|
|
156
|
+
|
|
157
|
+
def compact_command(ctx, _args: list[str]) -> None:
|
|
158
|
+
summary_text, turn_id = ctx.runtime.compact_session(
|
|
159
|
+
ctx.session_id,
|
|
160
|
+
reason="manual",
|
|
161
|
+
session_total_tokens_reset=0,
|
|
162
|
+
)
|
|
163
|
+
if not summary_text:
|
|
164
|
+
ctx.renderer.warn("No conversation history to compact.")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
ctx.renderer.info(f"Conversation compacted (turn_id={turn_id}).")
|
|
168
|
+
ctx.renderer.markdown(summary_text)
|
|
169
|
+
|
|
170
|
+
shell.command_registry.register(
|
|
171
|
+
Command(name="help", help="Show available commands", handler=help_command, aliases=("h", "?"))
|
|
172
|
+
)
|
|
173
|
+
shell.command_registry.register(
|
|
174
|
+
Command(name="exit", help="Exit the application", handler=exit_command, aliases=("quit", "q"))
|
|
175
|
+
)
|
|
176
|
+
shell.command_registry.register(
|
|
177
|
+
Command(name="clear", help="Clear the screen", handler=clear_command, aliases=("cls",))
|
|
178
|
+
)
|
|
179
|
+
shell.command_registry.register(
|
|
180
|
+
Command(name="session", help="Show current session info", handler=session_command)
|
|
181
|
+
)
|
|
182
|
+
shell.command_registry.register(
|
|
183
|
+
Command(name="prefs", help="View or set user preferences", handler=prefs_command)
|
|
184
|
+
)
|
|
185
|
+
shell.command_registry.register(
|
|
186
|
+
Command(name="app_data", help="Manage app-specific data", handler=app_data_command)
|
|
187
|
+
)
|
|
188
|
+
shell.command_registry.register(
|
|
189
|
+
Command(name="history", help="View conversation history", handler=history_command)
|
|
190
|
+
)
|
|
191
|
+
shell.command_registry.register(
|
|
192
|
+
Command(name="compact", help="Summarize conversation and save a checkpoint", handler=compact_command)
|
|
193
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Thin public CLI for package installation validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Sequence
|
|
7
|
+
|
|
8
|
+
from mash import __version__, get_docs_url
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="mash",
|
|
14
|
+
description="MashPy framework CLI.",
|
|
15
|
+
epilog=f"Documentation: {get_docs_url()}",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--version",
|
|
19
|
+
action="store_true",
|
|
20
|
+
help="Show installed mashpy version and documentation URL.",
|
|
21
|
+
)
|
|
22
|
+
return parser
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
26
|
+
parser = build_parser()
|
|
27
|
+
args = parser.parse_args(argv)
|
|
28
|
+
|
|
29
|
+
if args.version:
|
|
30
|
+
print(f"mashpy {__version__}")
|
|
31
|
+
print(f"Docs: {get_docs_url()}")
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
parser.print_help()
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Console rendering for CLI applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Any, ContextManager, Generator, List, Optional, Protocol
|
|
7
|
+
|
|
8
|
+
from rich import box
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.markdown import Markdown
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.theme import Theme
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Renderer(Protocol):
|
|
17
|
+
"""Protocol for renderers."""
|
|
18
|
+
|
|
19
|
+
def info(self, text: str) -> None:
|
|
20
|
+
"""Render informational text."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def warn(self, text: str) -> None:
|
|
24
|
+
"""Render warning text."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def error(self, text: str) -> None:
|
|
28
|
+
"""Render error text."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def markdown(self, text: str) -> None:
|
|
32
|
+
"""Render Markdown-formatted text."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def table(self, headers: List[str], rows: List[List[str]]) -> None:
|
|
36
|
+
"""Render a table."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def status(self, message: str) -> ContextManager[object]:
|
|
40
|
+
"""Return a status spinner context manager."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def clear(self) -> None:
|
|
44
|
+
"""Clear the terminal screen."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RichRenderer:
|
|
49
|
+
"""Rich-based renderer for formatted CLI output."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, console: Optional[Console] = None) -> None:
|
|
52
|
+
"""Initialize renderer.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
console: Rich console instance (creates new one if not provided).
|
|
56
|
+
"""
|
|
57
|
+
theme = Theme(
|
|
58
|
+
{
|
|
59
|
+
"info": "bold cyan",
|
|
60
|
+
"warn": "bold yellow",
|
|
61
|
+
"error": "bold red",
|
|
62
|
+
"muted": "dim",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
self._console = console or Console(theme=theme, soft_wrap=True)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def console(self) -> Console:
|
|
69
|
+
"""Get the underlying Rich console."""
|
|
70
|
+
return self._console
|
|
71
|
+
|
|
72
|
+
def info(self, text: str) -> None:
|
|
73
|
+
"""Render informational text."""
|
|
74
|
+
self._console.print(text, style="info")
|
|
75
|
+
|
|
76
|
+
def warn(self, text: str) -> None:
|
|
77
|
+
"""Render warning text."""
|
|
78
|
+
self._console.print(text, style="warn")
|
|
79
|
+
|
|
80
|
+
def error(self, text: str) -> None:
|
|
81
|
+
"""Render error text."""
|
|
82
|
+
self._console.print(text, style="error")
|
|
83
|
+
|
|
84
|
+
def markdown(self, text: str) -> None:
|
|
85
|
+
"""Render Markdown-formatted text."""
|
|
86
|
+
if not text.strip():
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
markdown = Markdown(text)
|
|
90
|
+
panel = Panel(
|
|
91
|
+
markdown,
|
|
92
|
+
title="Assistant",
|
|
93
|
+
border_style="cyan",
|
|
94
|
+
box=box.ASCII,
|
|
95
|
+
padding=(0, 1),
|
|
96
|
+
)
|
|
97
|
+
self._console.print(panel)
|
|
98
|
+
|
|
99
|
+
def table(self, headers: List[str], rows: List[List[str]]) -> None:
|
|
100
|
+
"""Render a table."""
|
|
101
|
+
table = Table(box=box.ASCII)
|
|
102
|
+
|
|
103
|
+
for header in headers:
|
|
104
|
+
table.add_column(header, style="cyan")
|
|
105
|
+
|
|
106
|
+
for row in rows:
|
|
107
|
+
table.add_row(*row)
|
|
108
|
+
|
|
109
|
+
self._console.print(table)
|
|
110
|
+
|
|
111
|
+
@contextmanager
|
|
112
|
+
def status(self, message: str) -> Generator[object, None, None]:
|
|
113
|
+
"""Return a status spinner context manager."""
|
|
114
|
+
with self._console.status(message) as status:
|
|
115
|
+
yield status
|
|
116
|
+
|
|
117
|
+
def clear(self) -> None:
|
|
118
|
+
"""Clear the terminal screen."""
|
|
119
|
+
self._console.clear()
|
|
120
|
+
|
|
121
|
+
def print(self, *args: Any, **kwargs: Any) -> None:
|
|
122
|
+
"""Print to console."""
|
|
123
|
+
self._console.print(*args, **kwargs)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Interactive REPL for CLI applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable, List, Optional
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit import PromptSession
|
|
9
|
+
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
10
|
+
from prompt_toolkit.completion import WordCompleter
|
|
11
|
+
from prompt_toolkit.history import FileHistory
|
|
12
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
13
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
14
|
+
from prompt_toolkit.styles import Style
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .types import CLIContext
|
|
18
|
+
from .commands import CommandRegistry
|
|
19
|
+
|
|
20
|
+
MessageHandler = Callable[["CLIContext", str], None]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class REPL:
|
|
24
|
+
"""Interactive read-eval-print loop."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
app_id: str,
|
|
29
|
+
command_registry: CommandRegistry,
|
|
30
|
+
message_handler: Optional[MessageHandler] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize REPL.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
app_id: Application ID for banner text and history file.
|
|
36
|
+
command_registry: Command registry for command completion.
|
|
37
|
+
message_handler: Handler for non-command messages.
|
|
38
|
+
"""
|
|
39
|
+
self.app_id = app_id
|
|
40
|
+
self.command_registry = command_registry
|
|
41
|
+
self.message_handler = message_handler
|
|
42
|
+
|
|
43
|
+
def run(self, ctx: CLIContext) -> None:
|
|
44
|
+
"""Run the REPL until user exits.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
ctx: CLI context.
|
|
48
|
+
"""
|
|
49
|
+
ctx.renderer.info(
|
|
50
|
+
f"{self.app_id} interactive session. Type /help for commands."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Setup prompt
|
|
54
|
+
command_words = self._get_command_words()
|
|
55
|
+
completer = WordCompleter(command_words, ignore_case=True)
|
|
56
|
+
history = FileHistory(str(self._get_history_path()))
|
|
57
|
+
key_bindings = self._build_key_bindings(ctx)
|
|
58
|
+
prompt_style = Style.from_dict({"prompt": "bold cyan"})
|
|
59
|
+
|
|
60
|
+
session: PromptSession[str] = PromptSession(
|
|
61
|
+
history=history,
|
|
62
|
+
completer=completer,
|
|
63
|
+
auto_suggest=AutoSuggestFromHistory(),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Main loop
|
|
67
|
+
while True:
|
|
68
|
+
try:
|
|
69
|
+
with patch_stdout():
|
|
70
|
+
line = session.prompt(
|
|
71
|
+
[("class:prompt", "> ")],
|
|
72
|
+
style=prompt_style,
|
|
73
|
+
key_bindings=key_bindings,
|
|
74
|
+
complete_while_typing=True,
|
|
75
|
+
)
|
|
76
|
+
except (KeyboardInterrupt, EOFError):
|
|
77
|
+
ctx.renderer.warn("Bye.")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
line = line.strip()
|
|
81
|
+
if not line:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
# Handle commands
|
|
86
|
+
if line.startswith("/"):
|
|
87
|
+
self.command_registry.execute(ctx, line)
|
|
88
|
+
# Handle messages
|
|
89
|
+
elif self.message_handler:
|
|
90
|
+
with ctx.renderer.status("Thinking..."):
|
|
91
|
+
self.message_handler(ctx, line)
|
|
92
|
+
else:
|
|
93
|
+
ctx.renderer.warn("Only slash commands are supported. Try /help.")
|
|
94
|
+
except SystemExit:
|
|
95
|
+
ctx.renderer.warn("Bye.")
|
|
96
|
+
raise
|
|
97
|
+
except Exception as e:
|
|
98
|
+
ctx.renderer.error(f"Error: {str(e)}")
|
|
99
|
+
|
|
100
|
+
def _get_command_words(self) -> List[str]:
|
|
101
|
+
"""Get list of command words for completion."""
|
|
102
|
+
words: List[str] = []
|
|
103
|
+
for command in self.command_registry.list_commands():
|
|
104
|
+
words.append(f"/{command.name}")
|
|
105
|
+
for alias in command.aliases:
|
|
106
|
+
words.append(f"/{alias}")
|
|
107
|
+
return sorted(set(words))
|
|
108
|
+
|
|
109
|
+
def _get_history_path(self) -> Path:
|
|
110
|
+
"""Get path to history file."""
|
|
111
|
+
slug = "".join(ch.lower() if ch.isalnum() else "_" for ch in self.app_id)
|
|
112
|
+
slug = slug.strip("_") or "mash"
|
|
113
|
+
return Path(f".{slug}_history")
|
|
114
|
+
|
|
115
|
+
def _build_key_bindings(self, ctx: CLIContext) -> KeyBindings:
|
|
116
|
+
"""Build key bindings for the REPL."""
|
|
117
|
+
bindings = KeyBindings()
|
|
118
|
+
|
|
119
|
+
@bindings.add("c-l")
|
|
120
|
+
def _clear_screen(_event: Any) -> None:
|
|
121
|
+
"""Clear screen on Ctrl+L."""
|
|
122
|
+
ctx.renderer.clear()
|
|
123
|
+
|
|
124
|
+
return bindings
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Composed CLI shell for Mash runtime engines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Sequence
|
|
7
|
+
|
|
8
|
+
from mash.core.context import Context, Response
|
|
9
|
+
from mash.runtime.client import MashAgentClient
|
|
10
|
+
from mash.runtime.definition import MashRuntimeDefinition
|
|
11
|
+
from mash.runtime.host import MashAgentHost
|
|
12
|
+
from mash.runtime.types import RuntimeTurnResult, SubAgentMetadata
|
|
13
|
+
|
|
14
|
+
from .chain_renderer import ChainOfThoughtRenderer
|
|
15
|
+
from .commands import Command, CommandRegistry
|
|
16
|
+
from .default_commands import register_default_commands
|
|
17
|
+
from .repl import REPL
|
|
18
|
+
from .render import RichRenderer
|
|
19
|
+
from .types import CLIContext
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class SubagentRegistration:
|
|
24
|
+
"""Subagent registration payload for host-backed shell composition."""
|
|
25
|
+
|
|
26
|
+
definition: MashRuntimeDefinition
|
|
27
|
+
metadata: SubAgentMetadata
|
|
28
|
+
agent_id: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CLIAppShell:
|
|
32
|
+
"""Interactive shell backed by a host-managed primary agent client."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, host: MashAgentHost, primary_agent_id: str) -> None:
|
|
35
|
+
self.host = host
|
|
36
|
+
self.primary_agent_id = primary_agent_id
|
|
37
|
+
self.client: MashAgentClient = host.get_client(primary_agent_id)
|
|
38
|
+
self.app_id = self.client.app_id
|
|
39
|
+
|
|
40
|
+
self.renderer = RichRenderer()
|
|
41
|
+
self.chain_renderer = ChainOfThoughtRenderer(console=self.renderer.console)
|
|
42
|
+
self.client.set_chain_renderer(self.chain_renderer)
|
|
43
|
+
|
|
44
|
+
self.command_registry = CommandRegistry(
|
|
45
|
+
app_id=self.client.app_id,
|
|
46
|
+
event_logger=self.client.get_event_logger(),
|
|
47
|
+
session_id=self.client.get_default_session_id(),
|
|
48
|
+
)
|
|
49
|
+
register_default_commands(self)
|
|
50
|
+
|
|
51
|
+
self.context = CLIContext(
|
|
52
|
+
app_id=self.client.app_id,
|
|
53
|
+
session_id=self.client.get_default_session_id(),
|
|
54
|
+
runtime=self.client,
|
|
55
|
+
renderer=self.renderer,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_definition(
|
|
60
|
+
cls,
|
|
61
|
+
definition: MashRuntimeDefinition,
|
|
62
|
+
*,
|
|
63
|
+
subagents: Sequence[SubagentRegistration] | None = None,
|
|
64
|
+
bind_host: str = "127.0.0.1",
|
|
65
|
+
) -> CLIAppShell:
|
|
66
|
+
"""Build a host-backed CLI shell from app definition(s)."""
|
|
67
|
+
host = MashAgentHost(bind_host=bind_host)
|
|
68
|
+
primary_agent_id = host.register_primary(definition)
|
|
69
|
+
for subagent in subagents or ():
|
|
70
|
+
host.register_subagent(
|
|
71
|
+
subagent.definition,
|
|
72
|
+
agent_id=subagent.agent_id,
|
|
73
|
+
metadata=subagent.metadata,
|
|
74
|
+
)
|
|
75
|
+
host.start()
|
|
76
|
+
return cls(host, primary_agent_id)
|
|
77
|
+
|
|
78
|
+
def register_command(self, command: Command) -> None:
|
|
79
|
+
"""Register a custom CLI command."""
|
|
80
|
+
self.command_registry.register(command)
|
|
81
|
+
|
|
82
|
+
def handle_message(
|
|
83
|
+
self,
|
|
84
|
+
message: str,
|
|
85
|
+
session_id: str | None = None,
|
|
86
|
+
) -> RuntimeTurnResult:
|
|
87
|
+
"""Process one message via agent client and return structured turn output."""
|
|
88
|
+
target_session_id = (session_id or self.context.session_id).strip()
|
|
89
|
+
if not target_session_id:
|
|
90
|
+
target_session_id = self.context.session_id
|
|
91
|
+
payload = self.client.invoke(message, session_id=target_session_id)
|
|
92
|
+
|
|
93
|
+
response_payload = payload.get("response")
|
|
94
|
+
if isinstance(response_payload, dict):
|
|
95
|
+
text = str(response_payload.get("text") or "")
|
|
96
|
+
signals = response_payload.get("signals")
|
|
97
|
+
metadata = response_payload.get("metadata")
|
|
98
|
+
else:
|
|
99
|
+
text = str(payload.get("text") or "")
|
|
100
|
+
signals = payload.get("signals")
|
|
101
|
+
metadata = payload.get("metadata")
|
|
102
|
+
|
|
103
|
+
response = Response(
|
|
104
|
+
text=text,
|
|
105
|
+
context=Context(),
|
|
106
|
+
signals=signals if isinstance(signals, dict) else {},
|
|
107
|
+
metadata=metadata if isinstance(metadata, dict) else {},
|
|
108
|
+
)
|
|
109
|
+
session_value = str(payload.get("session_id") or target_session_id).strip()
|
|
110
|
+
if not session_value:
|
|
111
|
+
session_value = target_session_id
|
|
112
|
+
|
|
113
|
+
session_total_tokens = payload.get("session_total_tokens", 0)
|
|
114
|
+
try:
|
|
115
|
+
parsed_session_total_tokens = int(session_total_tokens)
|
|
116
|
+
except (TypeError, ValueError):
|
|
117
|
+
parsed_session_total_tokens = 0
|
|
118
|
+
|
|
119
|
+
compaction_summary_text = payload.get("compaction_summary_text")
|
|
120
|
+
compaction_summary_turn_id = payload.get("compaction_summary_turn_id")
|
|
121
|
+
return RuntimeTurnResult(
|
|
122
|
+
session_id=session_value,
|
|
123
|
+
response=response,
|
|
124
|
+
compaction_summary_text=(
|
|
125
|
+
compaction_summary_text if isinstance(compaction_summary_text, str) else None
|
|
126
|
+
),
|
|
127
|
+
compaction_summary_turn_id=(
|
|
128
|
+
compaction_summary_turn_id if isinstance(compaction_summary_turn_id, str) else None
|
|
129
|
+
),
|
|
130
|
+
session_total_tokens=parsed_session_total_tokens,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def render_turn_result(self, ctx: CLIContext, result: RuntimeTurnResult) -> None:
|
|
134
|
+
"""Render runtime output to the terminal."""
|
|
135
|
+
if result.compaction_summary_text:
|
|
136
|
+
ctx.renderer.info("Compaction triggered - summary checkpoint created.")
|
|
137
|
+
ctx.renderer.markdown(result.compaction_summary_text)
|
|
138
|
+
if result.response.text:
|
|
139
|
+
ctx.renderer.markdown(result.response.text)
|
|
140
|
+
|
|
141
|
+
def handle_repl_message(self, ctx: CLIContext, message: str) -> None:
|
|
142
|
+
"""REPL callback for non-command user input."""
|
|
143
|
+
result = self.handle_message(message, session_id=ctx.session_id)
|
|
144
|
+
self.render_turn_result(ctx, result)
|
|
145
|
+
|
|
146
|
+
def run(self) -> None:
|
|
147
|
+
"""Run the interactive application."""
|
|
148
|
+
repl = REPL(
|
|
149
|
+
app_id=self.app_id,
|
|
150
|
+
command_registry=self.command_registry,
|
|
151
|
+
message_handler=self.handle_repl_message,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
repl.run(self.context)
|
|
156
|
+
except KeyboardInterrupt:
|
|
157
|
+
self.renderer.warn("\nBye.")
|
|
158
|
+
except SystemExit:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
def shutdown(self) -> None:
|
|
162
|
+
"""Shutdown shell and runtime resources."""
|
|
163
|
+
self.host.close()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""CLI shell data types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from mash.runtime.client import MashAgentClient
|
|
8
|
+
|
|
9
|
+
from .render import RichRenderer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CLIContext:
|
|
14
|
+
"""Context for CLI operations."""
|
|
15
|
+
|
|
16
|
+
app_id: str
|
|
17
|
+
session_id: str
|
|
18
|
+
runtime: MashAgentClient
|
|
19
|
+
renderer: RichRenderer
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mash-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive CLI shell package for Mash applications
|
|
5
|
+
Author: imsid
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: mashpy>=0.1.2
|
|
10
|
+
Requires-Dist: prompt_toolkit>=3.0.36
|
|
11
|
+
Requires-Dist: rich>=13.7.1
|
|
12
|
+
|
|
13
|
+
# mash-cli
|
|
14
|
+
|
|
15
|
+
Interactive CLI package for Mash applications.
|
|
16
|
+
|
|
17
|
+
It provides:
|
|
18
|
+
|
|
19
|
+
- `mash` console command
|
|
20
|
+
- `mash_cli.CLIAppShell` and related command primitives for app shells
|
|
21
|
+
|
|
22
|
+
Install options:
|
|
23
|
+
|
|
24
|
+
- `pip install mash-cli`
|
|
25
|
+
- `pip install "mashpy[cli]"`
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/mash_cli/__init__.py
|
|
4
|
+
src/mash_cli/chain_renderer.py
|
|
5
|
+
src/mash_cli/commands.py
|
|
6
|
+
src/mash_cli/default_commands.py
|
|
7
|
+
src/mash_cli/main.py
|
|
8
|
+
src/mash_cli/render.py
|
|
9
|
+
src/mash_cli/repl.py
|
|
10
|
+
src/mash_cli/shell.py
|
|
11
|
+
src/mash_cli/types.py
|
|
12
|
+
src/mash_cli.egg-info/PKG-INFO
|
|
13
|
+
src/mash_cli.egg-info/SOURCES.txt
|
|
14
|
+
src/mash_cli.egg-info/dependency_links.txt
|
|
15
|
+
src/mash_cli.egg-info/entry_points.txt
|
|
16
|
+
src/mash_cli.egg-info/requires.txt
|
|
17
|
+
src/mash_cli.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mash_cli
|