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.
@@ -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,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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """Mash CLI shell package."""
2
+
3
+ from .commands import Command, CommandRegistry
4
+ from .shell import CLIAppShell, SubagentRegistration
5
+ from .types import CLIContext
6
+
7
+ __all__ = ["CLIAppShell", "SubagentRegistration", "CLIContext", "Command", "CommandRegistry"]
@@ -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,2 @@
1
+ [console_scripts]
2
+ mash = mash_cli.main:main
@@ -0,0 +1,3 @@
1
+ mashpy>=0.1.2
2
+ prompt_toolkit>=3.0.36
3
+ rich>=13.7.1
@@ -0,0 +1 @@
1
+ mash_cli