Skip to content

Commit 3a80f47

Browse files
committed
feat: PyPI-ready package structure (v0.2.0)
- Reorganize code into agentzero_cli package - Fix relative imports for pip install compatibility - Add prompt-toolkit to dependencies - Update backend initialization to use get_backend() factory - Add docs/PUBLISHING.md with PyPI upload instructions - Build passes: twine check dist/* PASSED Ready for: pip install agentzero-cli Commands: a0, a0tui, a0cli, agentzero
1 parent e10c3a1 commit 3a80f47

50 files changed

Lines changed: 6733 additions & 19 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

agentzero_cli/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""
2+
AgentZero CLI - AI coding agent with security interceptor.
3+
4+
A TUI/CLI tool for AI-assisted coding with:
5+
- Security interceptor blocking dangerous commands
6+
- Multi-backend support (Local LLM, OpenRouter, etc.)
7+
- Human-in-the-loop approval for tool execution
8+
"""
9+
10+
__version__ = "0.2.0"
11+
__author__ = "Wojciech Wiesner"
12+
13+
from .backend import get_backend
14+
15+
__all__ = ["get_backend", "__version__"]

agentzero_cli/backend.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""
2+
Backend factory for AgentZeroCLI
3+
4+
Priority order (by capability):
5+
1. OpenRouter - cloud LLMs, best quality, data leaves network
6+
2. Agent Zero API - self-hosted, good quality, your infrastructure
7+
3. Local LLM - Ollama/LM Studio, SAFEST (data stays local), good quality
8+
4. Local Deterministic - pattern matching, offline, no LLM
9+
10+
SECURITY NOTE:
11+
All backends go through the same security flow:
12+
User Input → Backend → tool_request → ToolApprovalScreen → execute
13+
14+
The security interceptor is NEVER bypassed regardless of backend.
15+
See docs/SECURITY.md for details.
16+
"""
17+
18+
import os
19+
from typing import Protocol, AsyncGenerator, Any
20+
21+
22+
class BackendProtocol(Protocol):
23+
"""Protocol for all backends."""
24+
25+
async def send_prompt(self, user_text: str) -> AsyncGenerator[Any, None]: ...
26+
async def explain_risk(self, command: str) -> str: ...
27+
async def execute_tool(self, tool_name: str, command: str, cwd: str | None = None) -> AsyncGenerator[Any, None]: ...
28+
async def close(self) -> None: ...
29+
30+
31+
def get_backend() -> BackendProtocol:
32+
"""
33+
Factory function to get the best available backend.
34+
35+
Priority (checks in order, uses first available):
36+
1. Local LLM (SAFEST) - if LOCAL_LLM_URL is set - data stays on your network
37+
2. Agent Zero API - if AGENTZERO_API_URL is set - self-hosted
38+
3. OpenRouter - if OPENROUTER_API_KEY is set - cloud LLMs, best quality
39+
4. Local Deterministic - always available, pattern matching, no LLM
40+
41+
All tool_requests go through ToolApprovalScreen regardless of backend.
42+
"""
43+
from dotenv import load_dotenv
44+
load_dotenv()
45+
46+
# Priority 1: Local LLM (SAFEST - data never leaves your network)
47+
local_llm_url = os.getenv("LOCAL_LLM_URL")
48+
if local_llm_url:
49+
try:
50+
from .llm_providers.localllm import LocalLLMBackend
51+
backend = LocalLLMBackend(base_url=local_llm_url)
52+
print(f"[OK] Local LLM connected - {backend.model} (SAFEST)")
53+
return backend
54+
except ImportError as e:
55+
print(f"[WARN] LocalLLM import failed: {e}")
56+
except Exception as e:
57+
print(f"[WARN] LocalLLM init failed: {e}")
58+
59+
# Priority 2: Agent Zero API (self-hosted)
60+
agentzero_url = os.getenv("AGENTZERO_API_URL")
61+
if agentzero_url:
62+
try:
63+
from .llm_providers.agentzero import AgentZeroBackend
64+
backend = AgentZeroBackend(api_url=agentzero_url)
65+
print(f"[OK] Agent Zero connected ({agentzero_url})")
66+
return backend
67+
except ImportError as e:
68+
print(f"[WARN] AgentZero import failed: {e}")
69+
except Exception as e:
70+
print(f"[WARN] AgentZero init failed: {e}")
71+
72+
# Priority 3: OpenRouter (cloud - data leaves network)
73+
openrouter_key = os.getenv("OPENROUTER_API_KEY")
74+
if openrouter_key:
75+
try:
76+
from .llm_providers.openrouter import OpenRouterBackend
77+
backend = OpenRouterBackend(api_key=openrouter_key)
78+
print(f"[OK] OpenRouter connected ({len(backend.models)} models)")
79+
return backend
80+
except ImportError as e:
81+
print(f"[WARN] OpenRouter import failed: {e}")
82+
except Exception as e:
83+
print(f"[WARN] OpenRouter init failed: {e}")
84+
85+
# Priority 4: Local deterministic (no LLM, always works)
86+
try:
87+
from .llm_providers.local import LocalBackend
88+
backend = LocalBackend()
89+
print("[OK] Local mode (pattern matching, no LLM)")
90+
print("[INFO] For AI responses, set LOCAL_LLM_URL, AGENTZERO_API_URL, or OPENROUTER_API_KEY")
91+
return backend
92+
except ImportError:
93+
pass
94+
95+
# Fallback: Minimal mock (should never reach here)
96+
print("[WARN] No backend available, using minimal mock")
97+
return MinimalMockBackend()
98+
99+
100+
class MinimalMockBackend:
101+
"""Absolute minimal fallback - should never be used in practice."""
102+
103+
async def send_prompt(self, user_text: str):
104+
yield {
105+
"type": "final_response",
106+
"content": "No backend configured. Set OPENROUTER_API_KEY or AGENTZERO_API_URL in .env"
107+
}
108+
109+
async def explain_risk(self, command: str) -> str:
110+
return f"Cannot analyze '{command}' - no backend configured."
111+
112+
async def execute_tool(self, tool_name: str = "shell", command: str = "", cwd: str | None = None):
113+
from .tools.executor import execute_tool as real_execute
114+
async for event in real_execute(tool_name, command, cwd):
115+
yield event
116+
117+
async def close(self):
118+
pass
119+
120+
121+
# Backwards compatibility
122+
__all__ = ["get_backend", "BackendProtocol"]

agentzero_cli/cli/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""CLI module for AgentZeroCLI."""
2+
3+
from .app import CLIApp
4+
5+
__all__ = ["CLIApp"]

agentzero_cli/cli/app.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Main CLI application loop for AgentZeroCLI."""
2+
3+
import sys
4+
from pathlib import Path
5+
6+
import yaml
7+
from rich.console import Console
8+
9+
from ..backend import get_backend
10+
11+
from .approval import ToolApprovalHandler
12+
from .commands import CLISlashCommands
13+
from .input import InputHandler
14+
from .renderer import OutputRenderer
15+
from .setup_wizard import check_and_run_wizard
16+
17+
18+
def load_config(config_path: str = None) -> dict:
19+
"""Load configuration from YAML file.
20+
21+
Args:
22+
config_path: Path to config file (auto-detects if None)
23+
24+
Returns:
25+
Config dict (empty dict if file not found)
26+
"""
27+
if config_path:
28+
paths_to_try = [Path(config_path)]
29+
else:
30+
paths_to_try = [
31+
Path("config.yaml"),
32+
Path.home() / ".config" / "agentzero" / "config.yaml",
33+
]
34+
35+
for path in paths_to_try:
36+
if path.exists():
37+
with open(path, encoding="utf-8") as f:
38+
return yaml.safe_load(f) or {}
39+
return {}
40+
41+
42+
def save_config(config: dict, config_path: str = "config.yaml") -> None:
43+
"""Save configuration to YAML file.
44+
45+
Args:
46+
config: Config dict to save
47+
config_path: Path to config file
48+
"""
49+
with open(config_path, "w", encoding="utf-8") as f:
50+
yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
51+
52+
53+
class CLIApp:
54+
"""Main CLI application - Claude Code-like interface for Agent Zero."""
55+
56+
def __init__(self, config_path: str = None):
57+
"""Initialize CLI application.
58+
59+
Args:
60+
config_path: Path to config.yaml (auto-detected if None)
61+
"""
62+
self.console = Console()
63+
64+
if config_path:
65+
self.config_path = config_path
66+
else:
67+
resolved_path = check_and_run_wizard(self.console)
68+
if resolved_path is None:
69+
self.console.print("[red]Configuration required. Exiting.[/]")
70+
sys.exit(1)
71+
self.config_path = str(resolved_path)
72+
73+
self.config = load_config(self.config_path)
74+
self.renderer = OutputRenderer(self.console)
75+
76+
# Initialize backend using factory
77+
self.backend = get_backend()
78+
79+
# Initialize components
80+
workspace = getattr(self.backend, 'workspace_root', str(Path.cwd()))
81+
self.input_handler = InputHandler(workspace)
82+
self.commands = CLISlashCommands()
83+
84+
# Connect commands to input completer
85+
self.input_handler.set_commands(self.commands.commands)
86+
87+
self.approval = ToolApprovalHandler(
88+
self.renderer,
89+
self.input_handler,
90+
self.backend,
91+
)
92+
93+
self.running = True
94+
95+
async def run(self) -> None:
96+
"""Main application loop."""
97+
# Show header
98+
self.renderer.header(
99+
self.backend.api_url or "not configured",
100+
self.backend.project_name,
101+
self.backend.security_mode,
102+
)
103+
104+
# Main loop
105+
while self.running:
106+
try:
107+
user_input = await self.input_handler.get_input("> ")
108+
109+
if not user_input:
110+
continue
111+
112+
# Check for slash commands
113+
if await self.commands.execute(self, user_input):
114+
continue
115+
116+
# Process as agent message
117+
await self.handle_message(user_input)
118+
119+
except KeyboardInterrupt:
120+
self.renderer.goodbye()
121+
break
122+
except EOFError:
123+
self.renderer.goodbye()
124+
break
125+
except Exception as e:
126+
self.renderer.error(str(e))
127+
128+
async def handle_message(self, text: str) -> None:
129+
"""Send message to agent and handle response.
130+
131+
Args:
132+
text: User message text
133+
"""
134+
# Show user message
135+
self.renderer.user_message(text)
136+
137+
# Stream events from backend
138+
try:
139+
with self.console.status("[bold cyan]Thinking...", spinner="dots"):
140+
async for event in self.backend.send_prompt(text):
141+
await self._process_event(event)
142+
except Exception as e:
143+
self.renderer.error(f"Connection error: {e}")
144+
145+
async def _process_event(self, event: dict) -> None:
146+
"""Process a single event from backend.
147+
148+
Event types:
149+
- status: Connection/processing status
150+
- thinking/thought: Agent reasoning
151+
- final_response: Agent's response
152+
- tool_output: Tool execution output
153+
- tool_request: Tool approval request
154+
"""
155+
event_type = event.get("type", "")
156+
content = event.get("content", "")
157+
158+
if event_type == "status":
159+
# Filter keepalive spam, show news instead
160+
if "Oczekiwanie na odpowiedź" in content:
161+
from .news import get_random_news
162+
163+
item = get_random_news()
164+
if item:
165+
self.renderer.news_item(item)
166+
return
167+
self.renderer.status(content)
168+
169+
elif event_type in ("thinking", "thought"):
170+
self.renderer.thinking(content)
171+
172+
elif event_type == "final_response":
173+
self.renderer.agent_response(content)
174+
175+
elif event_type == "tool_output":
176+
self.renderer.tool_output(content)
177+
178+
elif event_type == "tool_request":
179+
await self._handle_tool_request(event)
180+
181+
async def _handle_tool_request(self, event: dict) -> None:
182+
"""Handle tool approval and execution.
183+
184+
Args:
185+
event: Tool request event
186+
"""
187+
# Request approval
188+
decision = await self.approval.request_approval(event)
189+
190+
if decision == "approved":
191+
# Execute tool
192+
with self.console.status("[bold cyan]Executing...", spinner="dots"):
193+
async for exec_event in self.backend.execute_tool(event):
194+
await self._process_event(exec_event)
195+
else:
196+
with self.console.status("[bold yellow]Rejected...", spinner="dots"):
197+
if hasattr(self.backend, "reject_tool"):
198+
async for exec_event in self.backend.reject_tool(event):
199+
await self._process_event(exec_event)

0 commit comments

Comments
 (0)