wagner-cli 0.1.0__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.
- wagner_cli/__init__.py +3 -0
- wagner_cli/client.py +108 -0
- wagner_cli/display.py +175 -0
- wagner_cli/main.py +302 -0
- wagner_cli/writer.py +65 -0
- wagner_cli-0.1.0.dist-info/METADATA +117 -0
- wagner_cli-0.1.0.dist-info/RECORD +10 -0
- wagner_cli-0.1.0.dist-info/WHEEL +5 -0
- wagner_cli-0.1.0.dist-info/entry_points.txt +2 -0
- wagner_cli-0.1.0.dist-info/top_level.txt +1 -0
wagner_cli/__init__.py
ADDED
wagner_cli/client.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Wagner API client."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_SERVER = "https://wagner.razzyshmazzy.com"
|
|
11
|
+
CONFIG_PATH = Path.home() / ".wagner" / "config.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WagnerClient:
|
|
15
|
+
"""Client for the Wagner orchestration API."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, server_url: str | None = None, api_key: str | None = None,
|
|
18
|
+
anthropic_key: str | None = None, timeout: float = 300.0):
|
|
19
|
+
config = self._load_config()
|
|
20
|
+
self.server_url = (server_url or config.get("server_url", DEFAULT_SERVER)).rstrip("/")
|
|
21
|
+
self.api_key = api_key or config.get("api_key", "")
|
|
22
|
+
# BYOK: the user's own Anthropic key. Sent only on conduct requests via
|
|
23
|
+
# the X-Anthropic-Key header — never on health/status/faces.
|
|
24
|
+
self.anthropic_key = anthropic_key or config.get("anthropic_key", "")
|
|
25
|
+
self.timeout = timeout
|
|
26
|
+
self._client = httpx.Client(
|
|
27
|
+
base_url=self.server_url,
|
|
28
|
+
timeout=timeout,
|
|
29
|
+
headers=self._headers(),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def _headers(self) -> dict:
|
|
33
|
+
h = {"Content-Type": "application/json"}
|
|
34
|
+
if self.api_key:
|
|
35
|
+
h["Authorization"] = f"Bearer {self.api_key}"
|
|
36
|
+
return h
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _load_config() -> dict:
|
|
40
|
+
if CONFIG_PATH.exists():
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
43
|
+
except Exception:
|
|
44
|
+
return {}
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def save_config(server_url: str, api_key: str = "", anthropic_key: str = ""):
|
|
49
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
CONFIG_PATH.write_text(json.dumps({
|
|
51
|
+
"server_url": server_url,
|
|
52
|
+
"api_key": api_key,
|
|
53
|
+
"anthropic_key": anthropic_key,
|
|
54
|
+
}, indent=2))
|
|
55
|
+
|
|
56
|
+
def _conduct_headers(self) -> dict:
|
|
57
|
+
"""Extra headers sent ONLY on conduct requests (BYOK key)."""
|
|
58
|
+
if self.anthropic_key:
|
|
59
|
+
return {"X-Anthropic-Key": self.anthropic_key}
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
def health(self) -> dict:
|
|
63
|
+
r = self._client.get("/api/health")
|
|
64
|
+
r.raise_for_status()
|
|
65
|
+
return r.json()
|
|
66
|
+
|
|
67
|
+
def conduct(self, prompt: str, synthesize: bool = True,
|
|
68
|
+
skip_clarification: bool = False, max_revisions: int = 1,
|
|
69
|
+
context: dict[str, str] | None = None) -> dict:
|
|
70
|
+
payload = {
|
|
71
|
+
"prompt": prompt,
|
|
72
|
+
"synthesize": synthesize,
|
|
73
|
+
"skip_clarification": skip_clarification,
|
|
74
|
+
"max_revisions": max_revisions,
|
|
75
|
+
}
|
|
76
|
+
if context:
|
|
77
|
+
payload["context"] = context
|
|
78
|
+
r = self._client.post("/api/conduct", json=payload, headers=self._conduct_headers())
|
|
79
|
+
r.raise_for_status()
|
|
80
|
+
return r.json()
|
|
81
|
+
|
|
82
|
+
def conduct_dry(self, prompt: str, context: dict[str, str] | None = None) -> dict:
|
|
83
|
+
payload = {"prompt": prompt}
|
|
84
|
+
if context:
|
|
85
|
+
payload["context"] = context
|
|
86
|
+
r = self._client.post("/api/conduct/dry", json=payload, headers=self._conduct_headers())
|
|
87
|
+
r.raise_for_status()
|
|
88
|
+
return r.json()
|
|
89
|
+
|
|
90
|
+
def faces(self) -> list[dict]:
|
|
91
|
+
r = self._client.get("/api/faces")
|
|
92
|
+
r.raise_for_status()
|
|
93
|
+
return r.json()
|
|
94
|
+
|
|
95
|
+
def status(self) -> dict:
|
|
96
|
+
r = self._client.get("/api/status")
|
|
97
|
+
r.raise_for_status()
|
|
98
|
+
return r.json()
|
|
99
|
+
|
|
100
|
+
def performances(self, limit: int = 10) -> list[dict]:
|
|
101
|
+
r = self._client.get(f"/api/performances?limit={limit}")
|
|
102
|
+
r.raise_for_status()
|
|
103
|
+
return r.json()
|
|
104
|
+
|
|
105
|
+
def gaps(self) -> dict:
|
|
106
|
+
r = self._client.get("/api/gaps")
|
|
107
|
+
r.raise_for_status()
|
|
108
|
+
return r.json()
|
wagner_cli/display.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Rich console rendering for Wagner CLI."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
7
|
+
from rich.syntax import Syntax
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from rich.columns import Columns
|
|
10
|
+
from rich import box
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def print_banner():
|
|
16
|
+
banner = Text("WAGNER", style="bold yellow")
|
|
17
|
+
console.print(Panel(
|
|
18
|
+
banner,
|
|
19
|
+
subtitle="AI Coding Orchestrator",
|
|
20
|
+
border_style="dim yellow",
|
|
21
|
+
padding=(0, 2),
|
|
22
|
+
))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def print_status(status: dict):
|
|
26
|
+
table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
|
|
27
|
+
table.add_column("Key", style="dim")
|
|
28
|
+
table.add_column("Value", style="bold")
|
|
29
|
+
table.add_row("Server", status.get("server", ""))
|
|
30
|
+
table.add_row("Faces", str(status.get("total_faces", 0)))
|
|
31
|
+
table.add_row("Performances", str(status.get("total_performances", 0)))
|
|
32
|
+
table.add_row("API", "connected" if status.get("api_connected") else "no key")
|
|
33
|
+
console.print(table)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def print_faces(faces: list[dict]):
|
|
37
|
+
table = Table(title="Specialist Faces", box=box.ROUNDED, border_style="dim")
|
|
38
|
+
table.add_column("Name", style="bold")
|
|
39
|
+
table.add_column("Mode", style="cyan", width=6)
|
|
40
|
+
table.add_column("Examples", justify="right")
|
|
41
|
+
table.add_column("Queries", justify="right")
|
|
42
|
+
table.add_column("Progress", width=12)
|
|
43
|
+
|
|
44
|
+
for f in sorted(faces, key=lambda x: x.get("examples", 0), reverse=True):
|
|
45
|
+
progress = f.get("graduation_progress", "0%")
|
|
46
|
+
bar_pct = min(100, int(progress.replace("%", "") or 0))
|
|
47
|
+
bar_filled = int(bar_pct / 5)
|
|
48
|
+
bar = "[yellow]" + "█" * bar_filled + "[dim]" + "░" * (20 - bar_filled)
|
|
49
|
+
|
|
50
|
+
table.add_row(
|
|
51
|
+
f.get("name", "?"),
|
|
52
|
+
f.get("mode", "rag").upper(),
|
|
53
|
+
str(f.get("examples", 0)),
|
|
54
|
+
str(f.get("total_queries", 0)),
|
|
55
|
+
bar,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
console.print(table)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def print_conduct_result(result: dict, show_synthesis: bool = True):
|
|
62
|
+
# Header
|
|
63
|
+
console.print()
|
|
64
|
+
console.print(Panel(
|
|
65
|
+
f'[bold]{result.get("prompt", "")[:100]}[/bold]',
|
|
66
|
+
title="Orchestration Complete",
|
|
67
|
+
border_style="green",
|
|
68
|
+
))
|
|
69
|
+
|
|
70
|
+
# Stats row
|
|
71
|
+
stats = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
|
|
72
|
+
stats.add_column("", style="dim")
|
|
73
|
+
stats.add_column("", style="bold")
|
|
74
|
+
stats.add_row("Subtasks", str(result.get("subtasks", 0)))
|
|
75
|
+
stats.add_row("Specialists", str(result.get("specialists_used", 0)))
|
|
76
|
+
stats.add_row("Latency", f'{result.get("total_latency_ms", 0):.0f}ms')
|
|
77
|
+
stats.add_row("API calls", str(result.get("total_api_calls", 0)))
|
|
78
|
+
|
|
79
|
+
gaps = result.get("gaps_detected", [])
|
|
80
|
+
if gaps:
|
|
81
|
+
stats.add_row("Gaps", ", ".join(gaps))
|
|
82
|
+
|
|
83
|
+
revised = result.get("revised", False)
|
|
84
|
+
if revised:
|
|
85
|
+
stats.add_row("Revised", "[green]Yes — critic triggered revision[/green]")
|
|
86
|
+
|
|
87
|
+
critique = result.get("critique")
|
|
88
|
+
if critique:
|
|
89
|
+
score = critique.get("score", "?")
|
|
90
|
+
stats.add_row("Critic score", f"{score}/10")
|
|
91
|
+
|
|
92
|
+
console.print(stats)
|
|
93
|
+
|
|
94
|
+
# Specialist outputs
|
|
95
|
+
outputs = result.get("specialist_outputs", [])
|
|
96
|
+
if outputs:
|
|
97
|
+
table = Table(title="Specialists", box=box.SIMPLE_HEAVY, border_style="dim")
|
|
98
|
+
table.add_column("Face", style="bold")
|
|
99
|
+
table.add_column("Task")
|
|
100
|
+
table.add_column("Conf", justify="right", style="cyan")
|
|
101
|
+
table.add_column("ms", justify="right", style="dim")
|
|
102
|
+
|
|
103
|
+
for o in outputs:
|
|
104
|
+
conf = o.get("confidence", 0)
|
|
105
|
+
conf_style = "green" if conf > 0.5 else "yellow" if conf > 0.3 else "red"
|
|
106
|
+
table.add_row(
|
|
107
|
+
o.get("face_name", "?"),
|
|
108
|
+
(o.get("task", "")[:60] + "...") if len(o.get("task", "")) > 60 else o.get("task", ""),
|
|
109
|
+
f"[{conf_style}]{conf:.0%}[/{conf_style}]",
|
|
110
|
+
f'{o.get("latency_ms", 0):.0f}',
|
|
111
|
+
)
|
|
112
|
+
console.print(table)
|
|
113
|
+
|
|
114
|
+
# Clarification
|
|
115
|
+
clarification = result.get("clarification")
|
|
116
|
+
if clarification and clarification.get("needs_clarification"):
|
|
117
|
+
console.print()
|
|
118
|
+
console.print(Panel(
|
|
119
|
+
"\n".join(f" {i+1}. {q}" for i, q in enumerate(clarification.get("questions", []))),
|
|
120
|
+
title="[yellow]Clarification Needed[/yellow]",
|
|
121
|
+
border_style="yellow",
|
|
122
|
+
))
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
# Synthesis
|
|
126
|
+
if show_synthesis and result.get("synthesis"):
|
|
127
|
+
console.print()
|
|
128
|
+
console.print("[dim]─── Synthesis ───[/dim]")
|
|
129
|
+
console.print(result["synthesis"])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def print_files_written(paths: list[str]):
|
|
133
|
+
console.print()
|
|
134
|
+
for p in paths:
|
|
135
|
+
console.print(f" [green]✓[/green] {p}")
|
|
136
|
+
console.print(f"\n [bold]{len(paths)} files written[/bold]")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def print_file_preview(previews: list[dict]):
|
|
140
|
+
table = Table(title="Generated Files", box=box.ROUNDED, border_style="dim")
|
|
141
|
+
table.add_column("File", style="bold")
|
|
142
|
+
table.add_column("Lines", justify="right")
|
|
143
|
+
table.add_column("Size", justify="right", style="dim")
|
|
144
|
+
|
|
145
|
+
for p in previews:
|
|
146
|
+
size = f'{p["chars"]}' if p["chars"] < 1024 else f'{p["chars"]/1024:.1f}K'
|
|
147
|
+
table.add_row(p["path"], str(p["lines"]), size)
|
|
148
|
+
|
|
149
|
+
console.print(table)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def spinner(message: str = "Orchestrating..."):
|
|
153
|
+
return Progress(
|
|
154
|
+
SpinnerColumn(style="yellow"),
|
|
155
|
+
TextColumn("[bold]{task.description}"),
|
|
156
|
+
TimeElapsedColumn(),
|
|
157
|
+
console=console,
|
|
158
|
+
transient=True,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def print_error(msg: str):
|
|
163
|
+
console.print(f"[red]Error:[/red] {msg}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def print_clarification_prompt(questions: list[str]) -> list[str]:
|
|
167
|
+
"""Display clarification questions and collect answers."""
|
|
168
|
+
console.print()
|
|
169
|
+
console.print("[yellow]Wagner needs clarification before proceeding:[/yellow]")
|
|
170
|
+
answers = []
|
|
171
|
+
for i, q in enumerate(questions):
|
|
172
|
+
console.print(f"\n [bold]{i+1}.[/bold] {q}")
|
|
173
|
+
answer = console.input(" [dim]→[/dim] ")
|
|
174
|
+
answers.append(answer)
|
|
175
|
+
return answers
|
wagner_cli/main.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Wagner CLI — main entry point."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from wagner_cli import __version__
|
|
10
|
+
from wagner_cli.client import WagnerClient, DEFAULT_SERVER
|
|
11
|
+
from wagner_cli.writer import parse_files, write_files, preview_files
|
|
12
|
+
from wagner_cli.display import (
|
|
13
|
+
console, print_banner, print_status, print_faces, print_conduct_result,
|
|
14
|
+
print_files_written, print_file_preview, print_error, spinner,
|
|
15
|
+
print_clarification_prompt,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_client(server: str | None = None) -> WagnerClient:
|
|
20
|
+
try:
|
|
21
|
+
return WagnerClient(server_url=server)
|
|
22
|
+
except Exception as e:
|
|
23
|
+
print_error(f"Cannot create client: {e}")
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _check_health(client: WagnerClient):
|
|
28
|
+
try:
|
|
29
|
+
client.health()
|
|
30
|
+
except httpx.ConnectError:
|
|
31
|
+
print_error(f"Cannot connect to {client.server_url}")
|
|
32
|
+
console.print("[dim]Run 'wagner config <url>' to set your server[/dim]")
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print_error(f"Server error: {e}")
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.group()
|
|
40
|
+
@click.version_option(__version__, "-v", "--version")
|
|
41
|
+
def cli():
|
|
42
|
+
"""Wagner — AI coding orchestrator with self-improving specialists.
|
|
43
|
+
|
|
44
|
+
\b
|
|
45
|
+
Quick start:
|
|
46
|
+
wagner config https://wagner.razzyshmazzy.com
|
|
47
|
+
wagner run "build a REST API with auth"
|
|
48
|
+
wagner run "build a CLI tool" --write
|
|
49
|
+
"""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@cli.command()
|
|
54
|
+
@click.argument("prompt", nargs=-1, required=True)
|
|
55
|
+
@click.option("--server", "-s", default=None, help="Wagner server URL")
|
|
56
|
+
@click.option("--write", "-w", is_flag=True, help="Write generated files to disk")
|
|
57
|
+
@click.option("--output", "-o", default=".", help="Output directory for generated files")
|
|
58
|
+
@click.option("--overwrite", is_flag=True, help="Overwrite existing files")
|
|
59
|
+
@click.option("--dry", is_flag=True, help="Dry run — decompose without synthesis")
|
|
60
|
+
@click.option("--no-clarify", is_flag=True, help="Skip clarification step")
|
|
61
|
+
@click.option("--no-critic", is_flag=True, help="Skip critic/revision step")
|
|
62
|
+
@click.option("--context", "-c", multiple=True, help="Source files to include as context (repeatable)")
|
|
63
|
+
def run(prompt, server, write, output, overwrite, dry, no_clarify, no_critic, context):
|
|
64
|
+
"""Run Wagner orchestration on a coding task.
|
|
65
|
+
|
|
66
|
+
\b
|
|
67
|
+
Examples:
|
|
68
|
+
wagner run "build a FastAPI app with auth"
|
|
69
|
+
wagner run "add pagination" --context app/routes.py app/models.py --write
|
|
70
|
+
wagner run "build a chat app" --dry
|
|
71
|
+
"""
|
|
72
|
+
prompt_text = " ".join(prompt)
|
|
73
|
+
client = _get_client(server)
|
|
74
|
+
_check_health(client)
|
|
75
|
+
|
|
76
|
+
print_banner()
|
|
77
|
+
|
|
78
|
+
# Read context files from disk
|
|
79
|
+
context_dict = None
|
|
80
|
+
if context:
|
|
81
|
+
context_dict = {}
|
|
82
|
+
for filepath in context:
|
|
83
|
+
p = Path(filepath)
|
|
84
|
+
if not p.exists():
|
|
85
|
+
print_error(f"Context file not found: {filepath}")
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
try:
|
|
88
|
+
context_dict[str(p)] = p.read_text(encoding="utf-8", errors="ignore")
|
|
89
|
+
console.print(f" [dim]context:[/dim] {p} ({len(p.read_text())} chars)")
|
|
90
|
+
except Exception as e:
|
|
91
|
+
print_error(f"Cannot read {filepath}: {e}")
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
console.print()
|
|
94
|
+
|
|
95
|
+
if dry:
|
|
96
|
+
with spinner() as progress:
|
|
97
|
+
progress.add_task("Decomposing...", total=None)
|
|
98
|
+
result = client.conduct_dry(prompt_text, context=context_dict)
|
|
99
|
+
print_conduct_result(result, show_synthesis=False)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
# Full conduct
|
|
103
|
+
with spinner() as progress:
|
|
104
|
+
progress.add_task("Orchestrating...", total=None)
|
|
105
|
+
result = client.conduct(
|
|
106
|
+
prompt_text,
|
|
107
|
+
synthesize=True,
|
|
108
|
+
skip_clarification=no_clarify,
|
|
109
|
+
max_revisions=0 if no_critic else 1,
|
|
110
|
+
context=context_dict,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Handle clarification
|
|
114
|
+
clarification = result.get("clarification")
|
|
115
|
+
if clarification and clarification.get("needs_clarification"):
|
|
116
|
+
questions = clarification.get("questions", [])
|
|
117
|
+
answers = print_clarification_prompt(questions)
|
|
118
|
+
|
|
119
|
+
# Re-submit with answers
|
|
120
|
+
augmented = prompt_text + "\n\nClarifications:\n"
|
|
121
|
+
for q, a in zip(questions, answers):
|
|
122
|
+
augmented += f"- {q}: {a}\n"
|
|
123
|
+
|
|
124
|
+
with spinner() as progress:
|
|
125
|
+
progress.add_task("Orchestrating with clarifications...", total=None)
|
|
126
|
+
result = client.conduct(
|
|
127
|
+
augmented,
|
|
128
|
+
synthesize=True,
|
|
129
|
+
skip_clarification=True,
|
|
130
|
+
max_revisions=0 if no_critic else 1,
|
|
131
|
+
context=context_dict,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
print_conduct_result(result)
|
|
135
|
+
|
|
136
|
+
# Parse and optionally write files
|
|
137
|
+
synthesis = result.get("synthesis", "")
|
|
138
|
+
if synthesis:
|
|
139
|
+
files = parse_files(synthesis)
|
|
140
|
+
if files:
|
|
141
|
+
previews = preview_files(files)
|
|
142
|
+
print_file_preview(previews)
|
|
143
|
+
|
|
144
|
+
if write:
|
|
145
|
+
written = write_files(files, Path(output), overwrite=overwrite)
|
|
146
|
+
print_files_written(written)
|
|
147
|
+
else:
|
|
148
|
+
console.print("\n[dim]Use --write to save files to disk[/dim]")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@cli.command()
|
|
152
|
+
@click.option("--server", "-s", default=None, help="Wagner server URL")
|
|
153
|
+
def status(server):
|
|
154
|
+
"""Show Wagner server status."""
|
|
155
|
+
try:
|
|
156
|
+
client = _get_client(server)
|
|
157
|
+
data = client.status()
|
|
158
|
+
health = client.health()
|
|
159
|
+
data["server"] = client.server_url
|
|
160
|
+
data["api_connected"] = health.get("api_connected", False)
|
|
161
|
+
print_banner()
|
|
162
|
+
print_status(data)
|
|
163
|
+
except httpx.ConnectError:
|
|
164
|
+
print_error("Cannot connect to server")
|
|
165
|
+
except Exception as e:
|
|
166
|
+
print_error(str(e))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@cli.command()
|
|
170
|
+
@click.option("--server", "-s", default=None, help="Wagner server URL")
|
|
171
|
+
def faces(server):
|
|
172
|
+
"""List all specialist faces."""
|
|
173
|
+
try:
|
|
174
|
+
client = _get_client(server)
|
|
175
|
+
data = client.faces()
|
|
176
|
+
print_banner()
|
|
177
|
+
print_faces(data)
|
|
178
|
+
except httpx.ConnectError:
|
|
179
|
+
print_error("Cannot connect to server")
|
|
180
|
+
except Exception as e:
|
|
181
|
+
print_error(str(e))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@cli.command()
|
|
185
|
+
@click.option("--server", "-s", default=None, help="Wagner server URL")
|
|
186
|
+
@click.option("--limit", "-n", default=10, help="Number of recent performances")
|
|
187
|
+
def history(server, limit):
|
|
188
|
+
"""Show recent orchestration history."""
|
|
189
|
+
try:
|
|
190
|
+
client = _get_client(server)
|
|
191
|
+
data = client.performances(limit)
|
|
192
|
+
print_banner()
|
|
193
|
+
|
|
194
|
+
from rich.table import Table
|
|
195
|
+
from rich import box
|
|
196
|
+
|
|
197
|
+
table = Table(title=f"Recent Performances (last {limit})", box=box.ROUNDED, border_style="dim")
|
|
198
|
+
table.add_column("Prompt", style="bold", max_width=50)
|
|
199
|
+
table.add_column("Tasks", justify="right")
|
|
200
|
+
table.add_column("Specs", justify="right")
|
|
201
|
+
table.add_column("Gaps", justify="right")
|
|
202
|
+
table.add_column("Latency", justify="right", style="dim")
|
|
203
|
+
table.add_column("Time", style="dim")
|
|
204
|
+
|
|
205
|
+
for p in data:
|
|
206
|
+
table.add_row(
|
|
207
|
+
p.get("prompt", "")[:50],
|
|
208
|
+
str(p.get("subtasks", 0)),
|
|
209
|
+
str(p.get("specialists", 0)),
|
|
210
|
+
str(p.get("gaps", 0)),
|
|
211
|
+
f'{p.get("latency_ms", 0):.0f}ms',
|
|
212
|
+
p.get("timestamp", "")[:16],
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
console.print(table)
|
|
216
|
+
except httpx.ConnectError:
|
|
217
|
+
print_error("Cannot connect to server")
|
|
218
|
+
except Exception as e:
|
|
219
|
+
print_error(str(e))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@cli.command()
|
|
223
|
+
@click.option("--server", "-s", default=None, help="Wagner server URL")
|
|
224
|
+
def gaps(server):
|
|
225
|
+
"""Show specialist gaps detected during orchestration."""
|
|
226
|
+
try:
|
|
227
|
+
client = _get_client(server)
|
|
228
|
+
data = client.gaps()
|
|
229
|
+
print_banner()
|
|
230
|
+
|
|
231
|
+
if not data:
|
|
232
|
+
console.print("[dim]No gaps detected yet. Run more prompts.[/dim]")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
from rich.table import Table
|
|
236
|
+
from rich import box
|
|
237
|
+
|
|
238
|
+
table = Table(title="Specialist Gaps", box=box.ROUNDED, border_style="dim")
|
|
239
|
+
table.add_column("Missing Specialist", style="bold")
|
|
240
|
+
table.add_column("Requests", justify="right", style="yellow")
|
|
241
|
+
|
|
242
|
+
for name, count in sorted(data.items(), key=lambda x: -x[1]):
|
|
243
|
+
table.add_row(name, str(count))
|
|
244
|
+
|
|
245
|
+
console.print(table)
|
|
246
|
+
except Exception as e:
|
|
247
|
+
print_error(str(e))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@cli.command()
|
|
251
|
+
@click.argument("api_key", required=False)
|
|
252
|
+
@click.option("--server", "-s", default=None, help="Wagner server URL")
|
|
253
|
+
@click.option("--anthropic-key", default=None,
|
|
254
|
+
help="Your own Anthropic API key (BYOK). Sent per-request, never stored server-side.")
|
|
255
|
+
def config(api_key, server, anthropic_key):
|
|
256
|
+
"""Configure Wagner.
|
|
257
|
+
|
|
258
|
+
\b
|
|
259
|
+
Bring Your Own Key (BYOK) — Wagner runs all LLM calls on your Anthropic key:
|
|
260
|
+
wagner config --anthropic-key sk-ant-your-key-here
|
|
261
|
+
|
|
262
|
+
\b
|
|
263
|
+
Combined with a custom server:
|
|
264
|
+
wagner config --anthropic-key sk-ant-xxx --server https://wagner.razzyshmazzy.com
|
|
265
|
+
|
|
266
|
+
\b
|
|
267
|
+
Set the Wagner access key (positional, optional):
|
|
268
|
+
wagner config wgn_your_api_key_here
|
|
269
|
+
|
|
270
|
+
Any flag you omit keeps its current value.
|
|
271
|
+
"""
|
|
272
|
+
# Merge with existing config so a partial update never clears other fields.
|
|
273
|
+
existing = WagnerClient._load_config()
|
|
274
|
+
new_server = server or existing.get("server_url") or DEFAULT_SERVER
|
|
275
|
+
new_api_key = api_key if api_key is not None else existing.get("api_key", "")
|
|
276
|
+
new_anthropic = anthropic_key if anthropic_key is not None else existing.get("anthropic_key", "")
|
|
277
|
+
|
|
278
|
+
WagnerClient.save_config(new_server, new_api_key, new_anthropic)
|
|
279
|
+
|
|
280
|
+
def _mask(s: str) -> str:
|
|
281
|
+
if not s:
|
|
282
|
+
return "(none)"
|
|
283
|
+
return f"{s[:8]}{'*' * max(0, len(s) - 8)}"
|
|
284
|
+
|
|
285
|
+
console.print(f"[green]✓[/green] Configured Wagner")
|
|
286
|
+
console.print(f" Server: {new_server}")
|
|
287
|
+
console.print(f" Access key: {_mask(new_api_key)}")
|
|
288
|
+
console.print(f" Anthropic key (BYOK): {_mask(new_anthropic)}")
|
|
289
|
+
|
|
290
|
+
# Test connection
|
|
291
|
+
try:
|
|
292
|
+
client = WagnerClient(server_url=new_server, api_key=new_api_key,
|
|
293
|
+
anthropic_key=new_anthropic)
|
|
294
|
+
health = client.health()
|
|
295
|
+
byok = " · byok supported" if health.get("byok") else ""
|
|
296
|
+
console.print(f" Status: [green]connected[/green] ({health.get('faces', 0)} faces{byok})")
|
|
297
|
+
except Exception as e:
|
|
298
|
+
console.print(f" Status: [red]cannot connect[/red] ({e})")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
cli()
|
wagner_cli/writer.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Parse Wagner synthesis output into files and write to disk."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_files(synthesis: str) -> list[dict]:
|
|
9
|
+
"""Parse ### FILE: markers from Wagner synthesis into file entries."""
|
|
10
|
+
if not synthesis:
|
|
11
|
+
return []
|
|
12
|
+
|
|
13
|
+
files = []
|
|
14
|
+
# Match ### FILE: path/to/file.ext followed by a code block
|
|
15
|
+
pattern = r'###\s*FILE:\s*(.+?)\n\s*```\w*\n(.*?)```'
|
|
16
|
+
matches = re.finditer(pattern, synthesis, re.DOTALL)
|
|
17
|
+
|
|
18
|
+
for match in matches:
|
|
19
|
+
filepath = match.group(1).strip()
|
|
20
|
+
code = match.group(2).strip()
|
|
21
|
+
if filepath and code:
|
|
22
|
+
files.append({"path": filepath, "code": code})
|
|
23
|
+
|
|
24
|
+
# Fallback: if no ### FILE markers, try to extract any code blocks
|
|
25
|
+
if not files:
|
|
26
|
+
code_blocks = re.findall(r'```\w*\n(.*?)```', synthesis, re.DOTALL)
|
|
27
|
+
for i, block in enumerate(code_blocks):
|
|
28
|
+
block = block.strip()
|
|
29
|
+
if len(block) > 50:
|
|
30
|
+
files.append({"path": f"output_{i}.txt", "code": block})
|
|
31
|
+
|
|
32
|
+
return files
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def write_files(files: list[dict], output_dir: Path, overwrite: bool = False) -> list[str]:
|
|
36
|
+
"""Write parsed files to disk. Returns list of written paths."""
|
|
37
|
+
written = []
|
|
38
|
+
output_dir = Path(output_dir)
|
|
39
|
+
|
|
40
|
+
for entry in files:
|
|
41
|
+
filepath = output_dir / entry["path"]
|
|
42
|
+
|
|
43
|
+
if filepath.exists() and not overwrite:
|
|
44
|
+
# Add .wagner suffix to avoid clobbering
|
|
45
|
+
filepath = filepath.with_suffix(filepath.suffix + ".wagner")
|
|
46
|
+
|
|
47
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
filepath.write_text(entry["code"] + "\n", encoding="utf-8")
|
|
49
|
+
written.append(str(filepath))
|
|
50
|
+
|
|
51
|
+
return written
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def preview_files(files: list[dict], max_lines: int = 5) -> list[dict]:
|
|
55
|
+
"""Return a preview of each file (first N lines + total line count)."""
|
|
56
|
+
previews = []
|
|
57
|
+
for entry in files:
|
|
58
|
+
lines = entry["code"].split("\n")
|
|
59
|
+
previews.append({
|
|
60
|
+
"path": entry["path"],
|
|
61
|
+
"lines": len(lines),
|
|
62
|
+
"chars": len(entry["code"]),
|
|
63
|
+
"preview": "\n".join(lines[:max_lines]),
|
|
64
|
+
})
|
|
65
|
+
return previews
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wagner-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI coding orchestrator with self-improving specialist agents
|
|
5
|
+
Author-email: razzyshmazzy <razzyshmazzy@gmail.com>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://wagner.razzyshmazzy.com
|
|
8
|
+
Project-URL: Dashboard, https://wagner.razzyshmazzy.com/api/health
|
|
9
|
+
Keywords: ai,coding,orchestration,agents,anthropic,claude,cli
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: Other/Proprietary License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: httpx>=0.25.0
|
|
23
|
+
Requires-Dist: rich>=13.0.0
|
|
24
|
+
Requires-Dist: click>=8.1.0
|
|
25
|
+
|
|
26
|
+
# Wagner CLI
|
|
27
|
+
|
|
28
|
+
AI coding orchestrator with self-improving specialist agents.
|
|
29
|
+
|
|
30
|
+
Wagner decomposes complex coding tasks across specialist agents, each trained on
|
|
31
|
+
domain-specific patterns. The more you use it, the smarter it gets.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install wagner-cli
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
Wagner runs on a **bring-your-own-key** model: orchestration (decomposition,
|
|
42
|
+
routing, specialist faces) runs on the Wagner server, while every model call
|
|
43
|
+
runs on **your** Anthropic key. Your key is sent per request and is never
|
|
44
|
+
stored or logged server-side.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# 1. Point Wagner at your own Anthropic key (stored in ~/.wagner/config.json)
|
|
48
|
+
wagner config --anthropic-key sk-ant-...
|
|
49
|
+
|
|
50
|
+
# 2. Orchestrate a coding task
|
|
51
|
+
wagner run "build a REST API with JWT auth and PostgreSQL"
|
|
52
|
+
|
|
53
|
+
# 3. Write the generated files to disk
|
|
54
|
+
wagner run "build a CLI tool that watches files" --write
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
More examples:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Dry run — see the decomposition without synthesis (no synthesis cost)
|
|
61
|
+
wagner run "build a chat app" --dry
|
|
62
|
+
|
|
63
|
+
# Skip clarification / critic passes
|
|
64
|
+
wagner run "build a FastAPI CRUD app" --no-clarify --no-critic
|
|
65
|
+
|
|
66
|
+
# Edit an existing codebase — pass relevant files as context
|
|
67
|
+
wagner run "add pagination to the list endpoint" \
|
|
68
|
+
--context app/routes.py app/models.py --write
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Commands
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
wagner run "prompt" Orchestrate a coding task
|
|
75
|
+
wagner config Configure your Anthropic key / server
|
|
76
|
+
wagner status Show server status and face count
|
|
77
|
+
wagner faces List all specialist faces
|
|
78
|
+
wagner history Show recent orchestrations
|
|
79
|
+
wagner gaps Show missing specialist domains
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## `wagner run` options
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
--write, -w Write generated files to the output directory
|
|
86
|
+
--output, -o DIR Output directory (default: current directory)
|
|
87
|
+
--overwrite Overwrite existing files
|
|
88
|
+
--dry Decompose only, no synthesis
|
|
89
|
+
--no-clarify Skip clarification questions
|
|
90
|
+
--no-critic Skip the critic/revision pass
|
|
91
|
+
--context, -c PATH Source file to include as context (repeatable)
|
|
92
|
+
--server, -s URL Override the server for this command
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Configuration
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# Set your Anthropic key (BYOK)
|
|
99
|
+
wagner config --anthropic-key sk-ant-...
|
|
100
|
+
|
|
101
|
+
# Use a custom server
|
|
102
|
+
wagner config --anthropic-key sk-ant-... --server https://wagner.razzyshmazzy.com
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Any flag you omit keeps its current value. Config lives in `~/.wagner/config.json`.
|
|
106
|
+
|
|
107
|
+
## How It Works
|
|
108
|
+
|
|
109
|
+
1. You type a prompt
|
|
110
|
+
2. Wagner's **Clarifier** checks if the prompt is ambiguous
|
|
111
|
+
3. The **Decomposer** breaks it into specialist subtasks
|
|
112
|
+
4. Each subtask is **dispatched** to the best specialist Face
|
|
113
|
+
5. The **Synthesizer** combines outputs into structured files
|
|
114
|
+
6. The **Critic** reviews and triggers revision if needed
|
|
115
|
+
7. Files are written to disk with `--write`
|
|
116
|
+
|
|
117
|
+
Every prompt improves the system. Faces accumulate examples and get smarter over time.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
wagner_cli/__init__.py,sha256=OVjw4OW8kgzWvH1ZJarJsv7_0LWxcHmqyIAxcu9LaoE,100
|
|
2
|
+
wagner_cli/client.py,sha256=9F0etR3OJ5nc-aXiiU_NSqxR_8S8r0sou2wfWJH5-W4,3653
|
|
3
|
+
wagner_cli/display.py,sha256=iNGuGbFtFReRE9FnoM9_8PXn3aC19rWbK3SuG4LSHCo,6005
|
|
4
|
+
wagner_cli/main.py,sha256=0bHJcWg86rkEx83FGgKf7DO8PzABJbhKKY2kh2xjzp8,10402
|
|
5
|
+
wagner_cli/writer.py,sha256=N_OlA3SZ3m2GDqR3pD3UDxQzOEVUyLbolwDXSaskTL4,2155
|
|
6
|
+
wagner_cli-0.1.0.dist-info/METADATA,sha256=uvHhl13s3dCZmOiS5kJgZhE1JQBgsCXPJp5aoEhale0,3877
|
|
7
|
+
wagner_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
wagner_cli-0.1.0.dist-info/entry_points.txt,sha256=OePlIrKbh2sVquH6a3CTKklxilbjMNKHKvx_XBD0RIo,47
|
|
9
|
+
wagner_cli-0.1.0.dist-info/top_level.txt,sha256=S6-2BghhyM95Ing387bkRBFEUCR6g1mJ7VRfeuzWE3Q,11
|
|
10
|
+
wagner_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wagner_cli
|