safe-agent-cli 0.2.0__py2.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.
- safe_agent/__init__.py +12 -0
- safe_agent/agent.py +309 -0
- safe_agent/cli.py +107 -0
- safe_agent/demo.py +123 -0
- safe_agent/demo_cli.py +98 -0
- safe_agent/marketing.py +431 -0
- safe_agent/marketing_cli.py +197 -0
- safe_agent/mcp_server.py +284 -0
- safe_agent_cli-0.2.0.dist-info/METADATA +11 -0
- safe_agent_cli-0.2.0.dist-info/RECORD +13 -0
- safe_agent_cli-0.2.0.dist-info/WHEEL +5 -0
- safe_agent_cli-0.2.0.dist-info/entry_points.txt +5 -0
- safe_agent_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
safe_agent/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Safe Agent - An AI coding agent you can actually trust.
|
|
3
|
+
|
|
4
|
+
Uses impact-preview to show exactly what will change before any action executes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
|
|
9
|
+
from safe_agent.agent import SafeAgent
|
|
10
|
+
from safe_agent.cli import main
|
|
11
|
+
|
|
12
|
+
__all__ = ["SafeAgent", "main", "__version__"]
|
safe_agent/agent.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Safe Agent - Core agent logic with impact preview integration.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import anthropic
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.prompt import Confirm
|
|
13
|
+
from rich.syntax import Syntax
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
# Import impact-preview components (package is impact-preview, module is agent_polis)
|
|
17
|
+
from agent_polis.actions.analyzer import ImpactAnalyzer
|
|
18
|
+
from agent_polis.actions.diff import format_diff_plain
|
|
19
|
+
from agent_polis.actions.models import ActionRequest, ActionType, RiskLevel
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SafeAgent:
|
|
25
|
+
"""
|
|
26
|
+
An AI coding agent with built-in impact preview.
|
|
27
|
+
|
|
28
|
+
Every file operation is analyzed and previewed before execution.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
model: str = "claude-sonnet-4-20250514",
|
|
34
|
+
auto_approve_low_risk: bool = False,
|
|
35
|
+
dry_run: bool = False,
|
|
36
|
+
working_directory: str | None = None,
|
|
37
|
+
):
|
|
38
|
+
self.model = model
|
|
39
|
+
self.auto_approve_low_risk = auto_approve_low_risk
|
|
40
|
+
self.dry_run = dry_run
|
|
41
|
+
self.working_directory = working_directory or os.getcwd()
|
|
42
|
+
|
|
43
|
+
self.client = anthropic.Anthropic()
|
|
44
|
+
self.analyzer = ImpactAnalyzer(working_directory=self.working_directory)
|
|
45
|
+
|
|
46
|
+
# Track changes for summary
|
|
47
|
+
self.changes_made: list[dict] = []
|
|
48
|
+
self.changes_rejected: list[dict] = []
|
|
49
|
+
|
|
50
|
+
async def run(self, task: str) -> dict[str, Any]:
|
|
51
|
+
"""
|
|
52
|
+
Execute a coding task with impact preview on all file operations.
|
|
53
|
+
"""
|
|
54
|
+
console.print("\n[bold]🤖 Planning changes...[/bold]\n")
|
|
55
|
+
|
|
56
|
+
# Get Claude to plan the changes
|
|
57
|
+
plan = await self._plan_changes(task)
|
|
58
|
+
|
|
59
|
+
if not plan.get("changes"):
|
|
60
|
+
console.print("[yellow]No file changes needed for this task.[/yellow]")
|
|
61
|
+
return {"success": True, "changes": []}
|
|
62
|
+
|
|
63
|
+
# Show plan
|
|
64
|
+
self._show_plan(plan)
|
|
65
|
+
|
|
66
|
+
# Execute each change with preview
|
|
67
|
+
for i, change in enumerate(plan["changes"], 1):
|
|
68
|
+
console.print(f"\n[bold]Step {i}/{len(plan['changes'])}[/bold]")
|
|
69
|
+
|
|
70
|
+
approved = await self._preview_and_approve(change)
|
|
71
|
+
|
|
72
|
+
if approved and not self.dry_run:
|
|
73
|
+
self._execute_change(change)
|
|
74
|
+
self.changes_made.append(change)
|
|
75
|
+
elif not approved:
|
|
76
|
+
self.changes_rejected.append(change)
|
|
77
|
+
console.print("[yellow]Skipped[/yellow]")
|
|
78
|
+
|
|
79
|
+
# Summary
|
|
80
|
+
self._show_summary()
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"success": True,
|
|
84
|
+
"changes_made": self.changes_made,
|
|
85
|
+
"changes_rejected": self.changes_rejected,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async def _plan_changes(self, task: str) -> dict[str, Any]:
|
|
89
|
+
"""Use Claude to plan what file changes are needed."""
|
|
90
|
+
|
|
91
|
+
system_prompt = """You are a coding assistant that plans file changes.
|
|
92
|
+
|
|
93
|
+
Given a task, analyze what file changes are needed and output a structured plan.
|
|
94
|
+
|
|
95
|
+
IMPORTANT: You must respond with ONLY a valid JSON object, no markdown, no explanation.
|
|
96
|
+
|
|
97
|
+
The JSON must have this structure:
|
|
98
|
+
{
|
|
99
|
+
"summary": "Brief description of what you'll do",
|
|
100
|
+
"changes": [
|
|
101
|
+
{
|
|
102
|
+
"action": "create" | "modify" | "delete",
|
|
103
|
+
"path": "relative/path/to/file.py",
|
|
104
|
+
"description": "What this change does",
|
|
105
|
+
"content": "Full file content (for create/modify)"
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
Rules:
|
|
111
|
+
- Only include file changes, not commands to run
|
|
112
|
+
- Use relative paths from the current directory
|
|
113
|
+
- For modifications, include the COMPLETE new file content
|
|
114
|
+
- Keep changes minimal and focused on the task
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
# List current files for context
|
|
118
|
+
files_context = self._get_files_context()
|
|
119
|
+
|
|
120
|
+
response = self.client.messages.create(
|
|
121
|
+
model=self.model,
|
|
122
|
+
max_tokens=4096,
|
|
123
|
+
system=system_prompt,
|
|
124
|
+
messages=[
|
|
125
|
+
{
|
|
126
|
+
"role": "user",
|
|
127
|
+
"content": f"Current directory: {self.working_directory}\n\nFiles:\n{files_context}\n\nTask: {task}"
|
|
128
|
+
}
|
|
129
|
+
],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Parse response
|
|
133
|
+
try:
|
|
134
|
+
import json
|
|
135
|
+
text = response.content[0].text.strip()
|
|
136
|
+
# Handle markdown code blocks
|
|
137
|
+
if text.startswith("```"):
|
|
138
|
+
text = text.split("```")[1]
|
|
139
|
+
if text.startswith("json"):
|
|
140
|
+
text = text[4:]
|
|
141
|
+
text = text.strip()
|
|
142
|
+
return json.loads(text)
|
|
143
|
+
except Exception as e:
|
|
144
|
+
console.print(f"[red]Failed to parse plan: {e}[/red]")
|
|
145
|
+
console.print(f"Response: {response.content[0].text[:500]}")
|
|
146
|
+
return {"summary": "Failed to plan", "changes": []}
|
|
147
|
+
|
|
148
|
+
def _get_files_context(self) -> str:
|
|
149
|
+
"""Get a summary of files in the working directory."""
|
|
150
|
+
lines = []
|
|
151
|
+
try:
|
|
152
|
+
for item in Path(self.working_directory).rglob("*"):
|
|
153
|
+
if item.is_file():
|
|
154
|
+
rel_path = item.relative_to(self.working_directory)
|
|
155
|
+
# Skip hidden files and common ignores
|
|
156
|
+
parts = str(rel_path).split("/")
|
|
157
|
+
if any(p.startswith(".") or p in ["node_modules", "__pycache__", "venv", ".venv"] for p in parts):
|
|
158
|
+
continue
|
|
159
|
+
lines.append(str(rel_path))
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
return "\n".join(lines[:100]) # Limit to 100 files
|
|
163
|
+
|
|
164
|
+
def _show_plan(self, plan: dict[str, Any]) -> None:
|
|
165
|
+
"""Display the planned changes."""
|
|
166
|
+
table = Table(title="📝 Planned Changes")
|
|
167
|
+
table.add_column("Action", style="cyan")
|
|
168
|
+
table.add_column("File", style="white")
|
|
169
|
+
table.add_column("Description", style="dim")
|
|
170
|
+
|
|
171
|
+
for change in plan.get("changes", []):
|
|
172
|
+
action_color = {
|
|
173
|
+
"create": "green",
|
|
174
|
+
"modify": "yellow",
|
|
175
|
+
"delete": "red",
|
|
176
|
+
}.get(change["action"], "white")
|
|
177
|
+
|
|
178
|
+
table.add_row(
|
|
179
|
+
f"[{action_color}]{change['action'].upper()}[/{action_color}]",
|
|
180
|
+
change["path"],
|
|
181
|
+
change.get("description", "")[:50],
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
console.print(table)
|
|
185
|
+
|
|
186
|
+
async def _preview_and_approve(self, change: dict) -> bool:
|
|
187
|
+
"""Preview a change using impact-preview and get approval."""
|
|
188
|
+
|
|
189
|
+
action = change["action"]
|
|
190
|
+
path = change["path"]
|
|
191
|
+
full_path = os.path.join(self.working_directory, path)
|
|
192
|
+
|
|
193
|
+
# Map to ActionType
|
|
194
|
+
if action == "create":
|
|
195
|
+
action_type = ActionType.FILE_CREATE
|
|
196
|
+
elif action == "modify":
|
|
197
|
+
action_type = ActionType.FILE_WRITE
|
|
198
|
+
elif action == "delete":
|
|
199
|
+
action_type = ActionType.FILE_DELETE
|
|
200
|
+
else:
|
|
201
|
+
console.print(f"[red]Unknown action: {action}[/red]")
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
# Create request
|
|
205
|
+
request = ActionRequest(
|
|
206
|
+
action_type=action_type,
|
|
207
|
+
target=full_path,
|
|
208
|
+
description=change.get("description", f"{action} {path}"),
|
|
209
|
+
payload={"content": change.get("content", "")},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Analyze with impact-preview
|
|
213
|
+
preview = await self.analyzer.analyze(request)
|
|
214
|
+
|
|
215
|
+
# Display preview
|
|
216
|
+
risk_emoji = {
|
|
217
|
+
RiskLevel.LOW: "🟢",
|
|
218
|
+
RiskLevel.MEDIUM: "🟡",
|
|
219
|
+
RiskLevel.HIGH: "🟠",
|
|
220
|
+
RiskLevel.CRITICAL: "🔴",
|
|
221
|
+
}.get(preview.risk_level, "⚪")
|
|
222
|
+
|
|
223
|
+
console.print(Panel(
|
|
224
|
+
f"[bold]{change.get('description', path)}[/bold]\n\n"
|
|
225
|
+
f"**File:** `{path}`\n"
|
|
226
|
+
f"**Action:** {action.upper()}\n"
|
|
227
|
+
f"**Risk:** {risk_emoji} {preview.risk_level.value.upper()}",
|
|
228
|
+
title=f"Impact Preview",
|
|
229
|
+
border_style="yellow" if preview.risk_level in [RiskLevel.HIGH, RiskLevel.CRITICAL] else "blue",
|
|
230
|
+
))
|
|
231
|
+
|
|
232
|
+
# Show risk factors
|
|
233
|
+
if preview.risk_factors:
|
|
234
|
+
console.print("[bold]Risk Factors:[/bold]")
|
|
235
|
+
for factor in preview.risk_factors:
|
|
236
|
+
console.print(f" ⚠️ {factor}")
|
|
237
|
+
|
|
238
|
+
# Show diff
|
|
239
|
+
if preview.file_changes:
|
|
240
|
+
console.print("\n[bold]Diff:[/bold]")
|
|
241
|
+
diff_text = format_diff_plain(preview.file_changes)
|
|
242
|
+
console.print(Syntax(diff_text, "diff", theme="monokai"))
|
|
243
|
+
elif action == "create" and change.get("content"):
|
|
244
|
+
console.print("\n[bold]New file content:[/bold]")
|
|
245
|
+
# Guess language from extension
|
|
246
|
+
ext = Path(path).suffix.lstrip(".")
|
|
247
|
+
lang = {"py": "python", "js": "javascript", "ts": "typescript", "json": "json", "md": "markdown"}.get(ext, "text")
|
|
248
|
+
console.print(Syntax(change["content"][:1000], lang, theme="monokai", line_numbers=True))
|
|
249
|
+
if len(change["content"]) > 1000:
|
|
250
|
+
console.print(f"[dim]... ({len(change['content'])} total characters)[/dim]")
|
|
251
|
+
|
|
252
|
+
console.print()
|
|
253
|
+
|
|
254
|
+
# Auto-approve low risk if enabled
|
|
255
|
+
if self.auto_approve_low_risk and preview.risk_level == RiskLevel.LOW:
|
|
256
|
+
console.print("[green]✓ Auto-approved (low risk)[/green]")
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
# Dry run mode
|
|
260
|
+
if self.dry_run:
|
|
261
|
+
console.print("[yellow]Dry run - not executing[/yellow]")
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
# Ask for approval
|
|
265
|
+
if preview.risk_level == RiskLevel.CRITICAL:
|
|
266
|
+
console.print("[bold red]⚠️ CRITICAL RISK - Please review carefully![/bold red]")
|
|
267
|
+
|
|
268
|
+
return Confirm.ask("Apply this change?", default=preview.risk_level == RiskLevel.LOW)
|
|
269
|
+
|
|
270
|
+
def _execute_change(self, change: dict) -> None:
|
|
271
|
+
"""Execute an approved change."""
|
|
272
|
+
action = change["action"]
|
|
273
|
+
path = os.path.join(self.working_directory, change["path"])
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
if action == "create" or action == "modify":
|
|
277
|
+
# Ensure directory exists
|
|
278
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
279
|
+
with open(path, "w") as f:
|
|
280
|
+
f.write(change.get("content", ""))
|
|
281
|
+
console.print(f"[green]✓ {action.title()}d: {change['path']}[/green]")
|
|
282
|
+
|
|
283
|
+
elif action == "delete":
|
|
284
|
+
if os.path.exists(path):
|
|
285
|
+
os.remove(path)
|
|
286
|
+
console.print(f"[green]✓ Deleted: {change['path']}[/green]")
|
|
287
|
+
else:
|
|
288
|
+
console.print(f"[yellow]File not found: {change['path']}[/yellow]")
|
|
289
|
+
|
|
290
|
+
except Exception as e:
|
|
291
|
+
console.print(f"[red]✗ Failed: {e}[/red]")
|
|
292
|
+
|
|
293
|
+
def _show_summary(self) -> None:
|
|
294
|
+
"""Show a summary of all changes."""
|
|
295
|
+
console.print("\n" + "=" * 50)
|
|
296
|
+
console.print("[bold]Summary[/bold]\n")
|
|
297
|
+
|
|
298
|
+
if self.changes_made:
|
|
299
|
+
console.print(f"[green]✓ {len(self.changes_made)} changes applied[/green]")
|
|
300
|
+
for change in self.changes_made:
|
|
301
|
+
console.print(f" - {change['action']}: {change['path']}")
|
|
302
|
+
|
|
303
|
+
if self.changes_rejected:
|
|
304
|
+
console.print(f"[yellow]⊘ {len(self.changes_rejected)} changes skipped[/yellow]")
|
|
305
|
+
for change in self.changes_rejected:
|
|
306
|
+
console.print(f" - {change['action']}: {change['path']}")
|
|
307
|
+
|
|
308
|
+
if not self.changes_made and not self.changes_rejected:
|
|
309
|
+
console.print("[dim]No changes to report[/dim]")
|
safe_agent/cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Safe Agent CLI - Run AI coding tasks with built-in safety.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
safe-agent "refactor auth to use JWT"
|
|
6
|
+
safe-agent --file task.md
|
|
7
|
+
safe-agent --interactive
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.prompt import Confirm
|
|
18
|
+
|
|
19
|
+
from safe_agent.agent import SafeAgent
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.command()
|
|
25
|
+
@click.argument("task", required=False)
|
|
26
|
+
@click.option("--file", "-f", type=click.Path(exists=True), help="Read task from file")
|
|
27
|
+
@click.option("--interactive", "-i", is_flag=True, help="Interactive mode")
|
|
28
|
+
@click.option("--auto-approve-low", is_flag=True, help="Auto-approve low-risk changes")
|
|
29
|
+
@click.option("--dry-run", is_flag=True, help="Preview only, don't execute")
|
|
30
|
+
@click.option("--model", default="claude-sonnet-4-20250514", help="Claude model to use")
|
|
31
|
+
def main(
|
|
32
|
+
task: str | None,
|
|
33
|
+
file: str | None,
|
|
34
|
+
interactive: bool,
|
|
35
|
+
auto_approve_low: bool,
|
|
36
|
+
dry_run: bool,
|
|
37
|
+
model: str,
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Safe Agent - An AI coding agent you can actually trust.
|
|
41
|
+
|
|
42
|
+
Run coding tasks with built-in impact preview. See exactly what will
|
|
43
|
+
change before any file is modified.
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
|
|
47
|
+
safe-agent "add error handling to api.py"
|
|
48
|
+
|
|
49
|
+
safe-agent "refactor the auth module" --dry-run
|
|
50
|
+
|
|
51
|
+
safe-agent --interactive
|
|
52
|
+
"""
|
|
53
|
+
# Check for API key
|
|
54
|
+
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
55
|
+
console.print("[red]Error: ANTHROPIC_API_KEY environment variable not set[/red]")
|
|
56
|
+
console.print("\nGet your API key at: https://console.anthropic.com/")
|
|
57
|
+
console.print("Then run: export ANTHROPIC_API_KEY=your-key-here")
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
# Get task
|
|
61
|
+
if file:
|
|
62
|
+
with open(file) as f:
|
|
63
|
+
task = f.read().strip()
|
|
64
|
+
elif interactive:
|
|
65
|
+
console.print(Panel(
|
|
66
|
+
"[bold]Safe Agent[/bold] - Interactive Mode\n\n"
|
|
67
|
+
"Type your coding task, then press Enter twice to submit.\n"
|
|
68
|
+
"Type 'quit' to exit.",
|
|
69
|
+
title="🛡️ Safe Agent",
|
|
70
|
+
))
|
|
71
|
+
lines = []
|
|
72
|
+
while True:
|
|
73
|
+
try:
|
|
74
|
+
line = input()
|
|
75
|
+
if line.lower() == "quit":
|
|
76
|
+
sys.exit(0)
|
|
77
|
+
if line == "" and lines and lines[-1] == "":
|
|
78
|
+
break
|
|
79
|
+
lines.append(line)
|
|
80
|
+
except EOFError:
|
|
81
|
+
break
|
|
82
|
+
task = "\n".join(lines).strip()
|
|
83
|
+
|
|
84
|
+
if not task:
|
|
85
|
+
console.print("[red]Error: No task provided[/red]")
|
|
86
|
+
console.print("\nUsage: safe-agent \"your task here\"")
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
# Show task
|
|
90
|
+
console.print(Panel(task, title="📋 Task", border_style="blue"))
|
|
91
|
+
|
|
92
|
+
# Create agent and run
|
|
93
|
+
agent = SafeAgent(
|
|
94
|
+
model=model,
|
|
95
|
+
auto_approve_low_risk=auto_approve_low,
|
|
96
|
+
dry_run=dry_run,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
asyncio.run(agent.run(task))
|
|
101
|
+
except KeyboardInterrupt:
|
|
102
|
+
console.print("\n[yellow]Interrupted[/yellow]")
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
safe_agent/demo.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Demo scaffolding for recording Safe Agent in action.
|
|
3
|
+
|
|
4
|
+
Goals:
|
|
5
|
+
- Quickly create a throwaway repo that demonstrates a risky edit being blocked.
|
|
6
|
+
- Provide a repeatable command script users can record via asciinema/ffmpeg.
|
|
7
|
+
|
|
8
|
+
We keep side effects minimal and self-contained under a temporary directory.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable
|
|
18
|
+
|
|
19
|
+
DEMO_TASK = "switch database config to production"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def prepare_demo_repo(target_dir: str | None = None) -> Path:
|
|
23
|
+
"""
|
|
24
|
+
Create a minimal demo repo with a risky config change.
|
|
25
|
+
|
|
26
|
+
Structure:
|
|
27
|
+
demo/
|
|
28
|
+
config/db.yaml # contains a dev URL
|
|
29
|
+
README.md # short instructions for the demo
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
base = Path(target_dir) if target_dir else Path(tempfile.mkdtemp(prefix="safe-agent-demo-"))
|
|
33
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
config_path = base / "config"
|
|
36
|
+
config_path.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
(config_path / "db.yaml").write_text(
|
|
39
|
+
"url: postgresql://localhost:5432/dev\n", encoding="utf-8"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
(base / "README.md").write_text(
|
|
43
|
+
"# Safe Agent Demo\n\n"
|
|
44
|
+
"Goal: show Safe Agent flagging a risky production DB change.\n\n"
|
|
45
|
+
"Steps:\n"
|
|
46
|
+
"1) Run the command from demo_script.sh (or below) inside this directory.\n"
|
|
47
|
+
"2) Approve/deny when prompted.\n"
|
|
48
|
+
"3) Record with `asciinema rec demo.cast -- safe-agent ...`.\n",
|
|
49
|
+
encoding="utf-8",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
(base / "demo_task.txt").write_text(DEMO_TASK, encoding="utf-8")
|
|
53
|
+
(base / ".gitignore").write_text("*.cast\n*.gif\n", encoding="utf-8")
|
|
54
|
+
|
|
55
|
+
# Provide a helper shell script the user can copy/paste.
|
|
56
|
+
(base / "demo_script.sh").write_text(
|
|
57
|
+
"#!/usr/bin/env bash\n"
|
|
58
|
+
"set -euo pipefail\n\n"
|
|
59
|
+
'echo "Running Safe Agent demo..."\n'
|
|
60
|
+
f"safe-agent --dry-run \"{DEMO_TASK}\"\n",
|
|
61
|
+
encoding="utf-8",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return base
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_asciinema_command(repo: Path) -> list[str]:
|
|
68
|
+
"""
|
|
69
|
+
Return a recommended asciinema record command for the demo repo.
|
|
70
|
+
"""
|
|
71
|
+
return [
|
|
72
|
+
"asciinema",
|
|
73
|
+
"rec",
|
|
74
|
+
"demo.cast",
|
|
75
|
+
"--title",
|
|
76
|
+
"Safe Agent Guardrail Demo",
|
|
77
|
+
"--cwd",
|
|
78
|
+
str(repo),
|
|
79
|
+
"--",
|
|
80
|
+
"safe-agent",
|
|
81
|
+
"--dry-run",
|
|
82
|
+
DEMO_TASK,
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def convert_cast_to_gif(cast_file: Path, out_gif: Path) -> list[str]:
|
|
87
|
+
"""
|
|
88
|
+
Provide a suggested command to convert asciinema cast to GIF.
|
|
89
|
+
|
|
90
|
+
We do not invoke the command automatically; we return it so the caller
|
|
91
|
+
can decide when/how to run (e.g., ffmpeg/asciinema-scenario).
|
|
92
|
+
"""
|
|
93
|
+
return [
|
|
94
|
+
"asciinema",
|
|
95
|
+
"play",
|
|
96
|
+
"--speed",
|
|
97
|
+
"1.1",
|
|
98
|
+
str(cast_file),
|
|
99
|
+
], [
|
|
100
|
+
"agg",
|
|
101
|
+
str(cast_file),
|
|
102
|
+
str(out_gif),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ensure_tools_available(tools: Iterable[str]) -> dict[str, bool]:
|
|
107
|
+
"""
|
|
108
|
+
Check for presence of required binaries in PATH.
|
|
109
|
+
"""
|
|
110
|
+
available: dict[str, bool] = {}
|
|
111
|
+
for tool in tools:
|
|
112
|
+
available[tool] = shutil.which(tool) is not None
|
|
113
|
+
return available
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def run_if_available(cmd: list[str]) -> int:
|
|
117
|
+
"""
|
|
118
|
+
Execute a command if the binary is present. Return exit code (0 if skipped).
|
|
119
|
+
"""
|
|
120
|
+
if shutil.which(cmd[0]) is None:
|
|
121
|
+
return 0
|
|
122
|
+
result = subprocess.run(cmd, check=False)
|
|
123
|
+
return result.returncode
|
safe_agent/demo_cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI helpers to prepare and record a Safe Agent demo.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
|
|
15
|
+
from safe_agent import __version__
|
|
16
|
+
from safe_agent.demo import (
|
|
17
|
+
DEMO_TASK,
|
|
18
|
+
build_asciinema_command,
|
|
19
|
+
convert_cast_to_gif,
|
|
20
|
+
ensure_tools_available,
|
|
21
|
+
prepare_demo_repo,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group()
|
|
28
|
+
def main() -> None:
|
|
29
|
+
"""Create and record the Safe Agent demo scenario."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@main.command()
|
|
33
|
+
@click.option(
|
|
34
|
+
"--output",
|
|
35
|
+
"-o",
|
|
36
|
+
default=None,
|
|
37
|
+
help="Directory to place the demo repo (default: temp dir).",
|
|
38
|
+
)
|
|
39
|
+
def prepare(output: str | None) -> None:
|
|
40
|
+
"""Set up a throwaway repo with the risky config change."""
|
|
41
|
+
|
|
42
|
+
repo = prepare_demo_repo(output)
|
|
43
|
+
console.print(
|
|
44
|
+
Panel(
|
|
45
|
+
f"Demo repo ready at: {repo}\n\n"
|
|
46
|
+
f"Task: \"{DEMO_TASK}\"\n"
|
|
47
|
+
"Next: run `safe-agent --dry-run \"{task}\"` inside that directory.\n"
|
|
48
|
+
"To record, see `safe-agent-demo record`.",
|
|
49
|
+
title="Demo Prepared",
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@main.command()
|
|
55
|
+
@click.option(
|
|
56
|
+
"--repo",
|
|
57
|
+
default=None,
|
|
58
|
+
help="Path to the prepared demo repo (defaults to CWD).",
|
|
59
|
+
)
|
|
60
|
+
def record(repo: str | None) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Print record commands (asciinema + GIF) for the demo.
|
|
63
|
+
|
|
64
|
+
We don't auto-run to avoid messing with user terminals; copy/paste instead.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
repo_path = Path(repo) if repo else Path.cwd()
|
|
68
|
+
tools = ensure_tools_available(["asciinema", "agg"])
|
|
69
|
+
missing = [name for name, ok in tools.items() if not ok]
|
|
70
|
+
|
|
71
|
+
cmd = build_asciinema_command(repo_path)
|
|
72
|
+
gif_cmds = convert_cast_to_gif(Path("demo.cast"), Path("demo.gif"))
|
|
73
|
+
|
|
74
|
+
console.print(Panel("Recording commands", title="Demo"))
|
|
75
|
+
console.print(f"Repo: {repo_path}")
|
|
76
|
+
console.print("\nRun to record:")
|
|
77
|
+
console.print(" ".join(cmd))
|
|
78
|
+
console.print("\nConvert to GIF (requires agg):")
|
|
79
|
+
console.print(" ".join(gif_cmds[1]))
|
|
80
|
+
|
|
81
|
+
if missing:
|
|
82
|
+
console.print(
|
|
83
|
+
f"[yellow]Missing tools:[/yellow] {', '.join(missing)}. "
|
|
84
|
+
"Install asciinema (and agg for GIF) before recording."
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
console.print("[green]All required tools detected.[/green]")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@main.command()
|
|
91
|
+
def version() -> None:
|
|
92
|
+
"""Show version for scripting."""
|
|
93
|
+
|
|
94
|
+
console.print(f"safe-agent-demo {__version__}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|