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 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()