smridhgupta commited on
Commit
4a35982
Β·
verified Β·
1 Parent(s): 675738f

Upload client.py

Browse files
Files changed (1) hide show
  1. client/client.py +1161 -0
client/client.py ADDED
@@ -0,0 +1,1161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Llama3 Agent β€” Deluxe Client (better-than-Gemini-CLI style)
5
+
6
+ Features
7
+ β€’ Pretty planning & execution UX (Rich)
8
+ β€’ Execute All or Step-by-Step with per-step confirmation
9
+ β€’ Edit plan JSON in your $EDITOR before you run it
10
+ β€’ Dry-run mode
11
+ β€’ Danger detection for risky shell commands (extra confirmation)
12
+ β€’ Transcripts & plans auto-saved to ~/.llama3_agent/sessions/
13
+ β€’ Health check & robust error handling
14
+ β€’ Server configurable via --server or $LLAMA_SERVER
15
+
16
+ Usage
17
+ python client.py # uses $LLAMA_SERVER or http://127.0.0.1:5005
18
+ python client.py --server http://IP:PORT
19
+
20
+ Hotkeys during prompts:
21
+ [a] Execute all [s] Step-by-step [e] Edit plan [d] Toggle dry-run [c] Cancel
22
+
23
+ """
24
+
25
+ import os
26
+ import re
27
+ import json
28
+ import time
29
+ import uuid
30
+ import shlex
31
+ import tempfile
32
+ import subprocess
33
+ from pathlib import Path
34
+ from datetime import datetime
35
+
36
+ import click
37
+ import requests
38
+ from requests.exceptions import RequestException
39
+
40
+ from rich.console import Console
41
+ from rich.panel import Panel
42
+ from rich.table import Table
43
+ from rich.prompt import Prompt, Confirm
44
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
45
+ from rich.syntax import Syntax
46
+ from rich.box import ROUNDED
47
+ from getpass import getpass
48
+ import platform
49
+ import io, contextlib, traceback
50
+
51
+ KEY_FILE = Path(__file__).resolve().parent / "key.json" # saves next to client.py (as requested)
52
+
53
+ def read_local_key() -> str | None:
54
+ try:
55
+ if KEY_FILE.exists():
56
+ data = json.loads(KEY_FILE.read_text())
57
+ k = (data or {}).get("api_key", "")
58
+ return k.strip() or None
59
+ except Exception:
60
+ return None
61
+ return None
62
+
63
+ def write_local_key(k: str) -> None:
64
+ KEY_FILE.write_text(json.dumps({"api_key": k}, indent=2))
65
+
66
+ def get_api_key() -> str:
67
+ # 1) env wins
68
+ k = os.environ.get("LLAMA_API_KEY", "").strip()
69
+ if k:
70
+ return k
71
+ # 2) key.json
72
+ k = read_local_key()
73
+ if k:
74
+ return k
75
+ # 3) first-run prompt
76
+ k = getpass("Enter API key: ").strip()
77
+ if not k:
78
+ raise RuntimeError("No API key provided.")
79
+ write_local_key(k)
80
+ return k
81
+
82
+ def auth_headers() -> dict:
83
+ try:
84
+ return {"Authorization": f"Bearer {get_api_key()}"}
85
+ except Exception:
86
+ return {}
87
+
88
+ # ──────────────────────────────────────────────────────────────────────────────
89
+ # Config & paths
90
+ # ──────────────────────────────────────────────────────────────────────────────
91
+ APP_HOME = Path(os.path.expanduser("~/.llama3_agent"))
92
+ SESS_DIR = APP_HOME / "sessions"
93
+ CONFIG_FILE = APP_HOME / "config.json"
94
+
95
+ console = Console(highlight=True, soft_wrap=False)
96
+
97
+ DEFAULT_SERVER = os.environ.get("LLAMA_SERVER", "https://tandevllc-axis.hf.space")
98
+
99
+ DANGER_PATTERNS = [
100
+ r"rm\s+-rf\s+/\b",
101
+ r"rm\s+-rf\s+--no-preserve-root",
102
+ r"mkfs\.",
103
+ r"dd\s+if=",
104
+ r":\(\)\s*\{\s*:.*\|\s*:\s*&\s*\};\s*:",
105
+ r"shutdown\b",
106
+ r"reboot\b",
107
+ r"init\s+0\b",
108
+ r"halt\b",
109
+ r"\|\s*(sh|bash)\s*$", # e.g., curl ... | sh
110
+ r"(curl|wget).*\|\s*(sh|bash)",
111
+ r"chown\s+-R\s+root:/\b",
112
+ r"chmod\s+777\s+-R\s+/\b",
113
+ ]
114
+
115
+ # ──────────────────────────────────────────────────────────────────────────────
116
+ # Utilities
117
+ # ──────────────────────────────────────────────────────────────────────────────
118
+ def ensure_dirs():
119
+ APP_HOME.mkdir(parents=True, exist_ok=True)
120
+ SESS_DIR.mkdir(parents=True, exist_ok=True)
121
+
122
+ def load_config():
123
+ ensure_dirs()
124
+ if CONFIG_FILE.exists():
125
+ try:
126
+ return json.loads(CONFIG_FILE.read_text())
127
+ except Exception:
128
+ return {}
129
+ return {}
130
+
131
+ def save_config(cfg):
132
+ ensure_dirs()
133
+ CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
134
+
135
+ def now_stamp():
136
+ return datetime.now().strftime("%Y%m%d_%H%M%S")
137
+
138
+ def new_session_path():
139
+ sid = f"{now_stamp()}_{uuid.uuid4().hex[:8]}"
140
+ folder = SESS_DIR / sid
141
+ folder.mkdir(parents=True, exist_ok=True)
142
+ return folder
143
+
144
+ def danger_check_shell(cmd: str) -> bool:
145
+ for pattern in DANGER_PATTERNS:
146
+ if re.search(pattern, cmd):
147
+ return True
148
+ # very broad β€œroot writing everywhere” heuristic
149
+ if re.search(r"\brm\s+-rf\s+[/~][\w\-/\.]*", cmd) and (" --preserve-root" not in cmd):
150
+ return True
151
+ return False
152
+
153
+ def _flatten_cmds_for_check(cmd_val):
154
+ if isinstance(cmd_val, str):
155
+ return [cmd_val]
156
+ if isinstance(cmd_val, dict):
157
+ return [v for v in cmd_val.values() if isinstance(v, str)]
158
+ return []
159
+
160
+ def pretty_json(obj) -> str:
161
+ return json.dumps(obj, indent=2, ensure_ascii=False)
162
+
163
+ def pager(text: str):
164
+ # Rich pager for long content
165
+ console.pager(text)
166
+
167
+ def editor_edit_json(original: dict) -> dict | None:
168
+ """Open $EDITOR to edit a JSON plan. Returns dict or None if cancelled/invalid."""
169
+ editor = os.environ.get("EDITOR", "nano")
170
+ with tempfile.NamedTemporaryFile("w+", suffix=".json", delete=False) as tf:
171
+ path = tf.name
172
+ tf.write(pretty_json(original))
173
+ tf.flush()
174
+ try:
175
+ subprocess.call([editor, path])
176
+ # Read back
177
+ new_text = Path(path).read_text()
178
+ try:
179
+ data = json.loads(new_text)
180
+ return data
181
+ except json.JSONDecodeError as e:
182
+ console.print(f"[red]Invalid JSON after editing: {e}[/red]")
183
+ return None
184
+ finally:
185
+ try:
186
+ os.unlink(path)
187
+ except Exception:
188
+ pass
189
+
190
+ def show_plan_table(plan: dict):
191
+ table = Table(title="🧠 Planned Steps", show_lines=True, box=ROUNDED)
192
+ table.add_column("Step#", style="bold cyan", no_wrap=True)
193
+ table.add_column("Action", style="bold green")
194
+ table.add_column("Quick details", style="yellow", overflow="fold")
195
+
196
+ steps = plan.get("steps", [])
197
+ if not isinstance(steps, list):
198
+ steps = []
199
+
200
+ for i, s in enumerate(steps, 1):
201
+ t = s.get("type", "?")
202
+ if t == "shell":
203
+ action = "Run shell"
204
+ cmd_val = s.get('cmd', '')
205
+ if isinstance(cmd_val, dict):
206
+ shown = ", ".join(f"{k}:{v}" for k, v in cmd_val.items() if isinstance(v, str))
207
+ details = f"[cyan]{shown}[/cyan]"
208
+ else:
209
+ details = f"[cyan]{cmd_val}[/cyan]"
210
+ if s.get("timeout"): details += f" (timeout={s['timeout']}s)"
211
+ if s.get("cwd"): details += f" [dim]cwd={s['cwd']}[/dim]"
212
+
213
+ elif t == "read_file":
214
+ action = "Read file"
215
+ details = s.get("path","")
216
+
217
+ elif t == "rewrite_file":
218
+ action = "rewrite_file"
219
+ details = s.get("path","?")
220
+
221
+ elif t in {"write_file","edit_file","append_file"}:
222
+ action = {"write_file":"Write file","edit_file":"Edit file","append_file":"Append file"}[t]
223
+ details = f"{s.get('path','')} [dim]mode={s.get('mode','w' if t!='append_file' else 'a')}[/dim]"
224
+
225
+ elif t == "generate_file":
226
+ action = "Generate file"
227
+ fmt = s.get('format','text')
228
+ details = f"{s.get('path','')} ({fmt}, {s.get('length','medium')})"
229
+
230
+ elif t == "generate_tree":
231
+ action = "Generate project tree"
232
+ base = s.get("base", ".")
233
+ files = s.get("files", [])
234
+ details = f"{base} β€” {len(files)} file(s)"
235
+
236
+ elif t == "generate_large_file":
237
+ action = "Generate large file"
238
+ details = f"{s.get('path','?')} [{len(s.get('chunks',[]))} chunks]"
239
+
240
+ elif t == "mkdirs":
241
+ action = "Make directories"
242
+ details = ", ".join(s.get("paths", []) or [])
243
+
244
+ elif t == "python":
245
+ action = "Run Python"
246
+ code = (s.get("code","").strip().splitlines() or [""])[0][:60]
247
+ details = code + ("…" if len(code)==60 else "")
248
+
249
+ elif t == "respond_llm":
250
+ action = "LLM respond"
251
+ inst = (s.get("instruction","") or "").strip()
252
+ details = (inst[:80] + "…") if len(inst) > 80 else inst
253
+
254
+ elif t == "respond":
255
+ action = "Respond"
256
+ details = (s.get("text","")[:80] + "…") if len(s.get("text","")) > 80 else s.get("text","")
257
+
258
+ elif t == "fs":
259
+ action = "fs"
260
+ op = s.get("op", "?")
261
+ path_or_patt = s.get("path") or s.get("pattern") or ""
262
+ details = f"{op} {path_or_patt}"
263
+
264
+ else:
265
+ action = t
266
+ details = pretty_json(s)
267
+
268
+ table.add_row(str(i), action, details)
269
+ console.print(table)
270
+
271
+ def render_result(res: dict):
272
+ """Pretty renderer for a single step result."""
273
+ rtype = res.get("type", "unknown")
274
+
275
+ # ───────────────────────── mkdirs ─────────────────────────
276
+ if rtype == "mkdirs":
277
+ created = res.get("created", [])
278
+ ok = res.get("ok", True)
279
+ body = "No directories created." if not created else "[bold]Created:[/bold]\n" + "\n".join(created)
280
+ console.print(Panel(body, title="πŸ“ mkdirs", border_style=("green" if ok else "red")))
281
+ return
282
+
283
+ # ─────────────────────── generate_tree ────────────────────
284
+ if rtype == "generate_tree":
285
+ base = res.get("base","?")
286
+ written = res.get("written", [])
287
+ ok = res.get("ok", True)
288
+ n = len(written)
289
+ lines = [f"{w.get('path','?')}" for w in written[:20]]
290
+ more = f"\n… and {n-20} more" if n > 20 else ""
291
+ body = f"[bold]Base:[/bold] {base}\n[bold]Files written:[/bold] {n}\n" + ("\n".join(lines) + more if n else "None")
292
+ console.print(Panel(body, title="🌲 Project tree", border_style=("green" if ok else "red")))
293
+ return
294
+
295
+ # ─────────────────── generate_large_file ───────────────────
296
+ if rtype == "generate_large_file":
297
+ ok = res.get("ok", True)
298
+ path = res.get("path","?")
299
+ chunks = res.get("chunks",0)
300
+ bytes_ = res.get("bytes",0)
301
+ body = f"[bold]Path:[/bold] {path}\n[bold]Chunks:[/bold] {chunks}\n[bold]Bytes:[/bold] {bytes_}"
302
+ console.print(Panel(body, title="🧱 Large file", border_style=("green" if ok else "red")))
303
+ return
304
+
305
+ # ───────────────────────── shell ─────────────────────────
306
+ if rtype == "shell":
307
+ cmd = res.get("cmd", "")
308
+ rc = res.get("returncode")
309
+ ok = (rc == 0)
310
+ icon = "βœ…" if ok else "❌"
311
+ title = f"{icon} Shell β€” {('Succeeded' if ok else 'Failed')} (rc={rc})"
312
+ subtitle = f"[bold]Command:[/bold] [cyan]{cmd}[/cyan]"
313
+ if res.get("cwd"):
314
+ subtitle += f"\n[bold]CWD:[/bold] {res['cwd']}"
315
+ console.print(Panel(subtitle, title=title, border_style=("green" if ok else "red")))
316
+
317
+ # NEW: show auto-install logs if the server installed missing tools
318
+ pre = res.get("preinstall")
319
+ if pre:
320
+ if len(pre) <= 1200 and pre.count("\n") <= 40:
321
+ console.print(Panel(pre, title="🧰 Preinstall (auto-installed tools)", border_style="yellow"))
322
+ else:
323
+ console.print(Panel("Auto-install log is long. Opening pager…", title="🧰 Preinstall", border_style="yellow"))
324
+ pager(pre)
325
+ console.rule(style="dim")
326
+
327
+ stdout = res.get("stdout") or ""
328
+ stderr = res.get("stderr") or ""
329
+
330
+ if not stdout.strip() and not stderr.strip():
331
+ hint = ""
332
+ import re as _re
333
+ if _re.search(r"\bdig\b", cmd) and _re.search(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", cmd) and "-x" not in cmd:
334
+ hint = "\n[dim]Hint: Reverse DNS uses[/dim] [cyan]dig -x <IP> +short[/cyan]"
335
+ console.print(Panel("No output from command." + hint, border_style="yellow"))
336
+ return
337
+
338
+ if stdout.strip():
339
+ console.print("[bold]stdout[/bold]")
340
+ if len(stdout) <= 1200 and stdout.count("\n") <= 40:
341
+ console.print(Syntax(stdout.rstrip("\n"), "bash", theme="ansi_dark"))
342
+ else:
343
+ console.print("[cyan]Opening pager for long stdout…[/cyan]")
344
+ pager(stdout)
345
+ console.rule(style="dim")
346
+
347
+ if stderr.strip():
348
+ console.print("[bold red]stderr[/bold red]")
349
+ if len(stderr) <= 1200 and stderr.count("\n") <= 40:
350
+ console.print(Syntax(stderr.rstrip("\n"), "bash", theme="ansi_dark"))
351
+ else:
352
+ console.print("[cyan]Opening pager for long stderr…[/cyan]")
353
+ pager(stderr)
354
+ console.rule(style="dim")
355
+ return
356
+
357
+ # ──────────────────────── read_file ───────────────────────
358
+ if rtype == "read_file":
359
+ path = res.get("path", "?")
360
+ content = res.get("content", "")
361
+ size = len(content.encode("utf-8"))
362
+ title = f"πŸ“– Read File β€” {path}"
363
+ meta = f"[bold]Bytes:[/bold] {size}"
364
+ console.print(Panel(meta, title=title, border_style="cyan"))
365
+
366
+ ext = Path(path).suffix.lower()
367
+ lang = "text"
368
+ if ext in {".py"}: lang = "python"
369
+ elif ext in {".sh", ".bash"}: lang = "bash"
370
+ elif ext in {".html", ".htm"}: lang = "html"
371
+ elif ext in {".md"}: lang = "markdown"
372
+ elif ext in {".json"}: lang = "json"
373
+ elif ext in {".yml", ".yaml"}: lang = "yaml"
374
+
375
+ if len(content) <= 1500 and content.count("\n") <= 60:
376
+ console.print(Syntax(content, lang, theme="ansi_dark"))
377
+ else:
378
+ console.print("[cyan]Opening pager for long content…[/cyan]")
379
+ pager(content)
380
+ return
381
+
382
+ # ───────────────────────── fs ─────────────────────────
383
+ if rtype == "fs":
384
+ op = res.get("op")
385
+ title = f"β„Ή fs β€” {op}"
386
+ if op == "list":
387
+ entries = res.get("entries", []) or []
388
+ body = f"[bold]Path:[/bold] {res.get('path','?')}\n[bold]Count:[/bold] {len(entries)}"
389
+ if entries:
390
+ body += "\n" + "\n".join(entries[:50])
391
+ if len(entries) > 50:
392
+ body += f"\n… and {len(entries)-50} more"
393
+ console.print(Panel(body, title=title, border_style="cyan"))
394
+ return
395
+
396
+ if op == "read":
397
+ content = res.get("content", "")
398
+ path = res.get("path", "?")
399
+ meta = f"[bold]Path:[/bold] {path} [bold]Bytes:[/bold] {len(content.encode())}"
400
+ console.print(Panel(meta, title=title, border_style="cyan"))
401
+ if len(content) <= 1500 and content.count("\n") <= 60:
402
+ console.print(Syntax(content, "markdown", theme="ansi_dark"))
403
+ else:
404
+ console.print("[cyan]Opening pager for long content…[/cyan]")
405
+ pager(content)
406
+ return
407
+
408
+ if op == "exists":
409
+ body = f"[bold]Path:[/bold] {res.get('path','?')}\n[bold]Exists:[/bold] {res.get('exists')}"
410
+ console.print(Panel(body, title=title, border_style="cyan"))
411
+ return
412
+
413
+ if op == "glob":
414
+ patt = res.get("pattern", "")
415
+ matches = res.get("matches", []) or []
416
+ body = f"[bold]Pattern:[/bold] {patt}\n[bold]Count:[/bold] {len(matches)}"
417
+ if matches:
418
+ body += "\n" + "\n".join(matches[:50])
419
+ if len(matches) > 50:
420
+ body += f"\n… and {len(matches)-50} more"
421
+ console.print(Panel(body, title=title, border_style="cyan"))
422
+ return
423
+
424
+ # default fallback
425
+ console.print(Panel(pretty_json(res), title=title, border_style="cyan"))
426
+ return
427
+
428
+ # ─────────────────────── generate_file ─────────────────────
429
+ if rtype == "generate_file":
430
+ path = res.get("path", "?")
431
+ n = res.get("bytes", 0)
432
+ ok = (res.get("status") == "ok")
433
+ icon = "βœ…" if ok else "❌"
434
+ title = f"{icon} Generated File β€” {path}"
435
+ meta = f"[bold]Bytes written:[/bold] {n}"
436
+ tip = f"[dim]Tip: try[/dim] [cyan]head -n 40 {path}[/cyan]"
437
+ console.print(Panel(f"{meta}\n{tip}", title=title, border_style=("green" if ok else "red")))
438
+ return
439
+
440
+ # ───────────────────── write/edit/append ───────────────────
441
+ if rtype in {"write_file", "edit_file", "append_file"}:
442
+ path = res.get("path", "?")
443
+ mode = res.get("mode", "w" if rtype != "append_file" else "a")
444
+ ok = (res.get("status") == "ok")
445
+ icon = "βœ…" if ok else "❌"
446
+ action = {"write_file":"Write","edit_file":"Edit","append_file":"Append"}[rtype]
447
+ console.print(Panel(f"[bold]Path:[/bold] {path}\n[bold]Mode:[/bold] {mode}",
448
+ title=f"{icon} {action} File",
449
+ border_style=("green" if ok else "red")))
450
+ return
451
+
452
+ # ────────────────────────── python ─────────────────────────
453
+ if rtype == "python":
454
+ out = res.get("stdout", "")
455
+ title = "🐍 Python β€” Output"
456
+ if out.strip():
457
+ if len(out) <= 1500 and out.count("\n") <= 60:
458
+ console.print(Panel(Syntax(out, "python", theme="ansi_dark"),
459
+ title=title, border_style="magenta"))
460
+ else:
461
+ console.print(Panel("Output is long. Opening pager…", title=title, border_style="magenta"))
462
+ pager(out)
463
+ else:
464
+ console.print(Panel("No output.", title=title, border_style="magenta"))
465
+ return
466
+
467
+ # ───────────────────────── respond ─────────────────────────
468
+ if rtype == "respond":
469
+ console.print(Panel(res.get("text", ""), title="πŸ’¬ Response", border_style="blue"))
470
+ return
471
+
472
+ # ───────────────────────── unknown ─────────────────────────
473
+ console.print(Panel(pretty_json(res), title=f"β„Ή {rtype}", border_style="yellow"))
474
+
475
+ def show_result_panels(results: list[dict]):
476
+ """Pretty print execution results with smart renderers per step type."""
477
+ if not results:
478
+ console.print("[yellow]No steps executed.[/yellow]")
479
+ return
480
+ for i, res in enumerate(results, 1):
481
+ console.rule(f"[bold magenta]Step {i}[/bold magenta]")
482
+ render_result(res)
483
+
484
+ def warn_if_danger(plan: dict) -> bool:
485
+ """Return True if anything looks dangerous; also print warnings."""
486
+ dangerous = []
487
+ for idx, step in enumerate(plan.get("steps", []), 1):
488
+ if step.get("type") == "shell":
489
+ for cand in _flatten_cmds_for_check(step.get("cmd", "")):
490
+ if danger_check_shell(cand):
491
+ dangerous.append((idx, cand))
492
+ break
493
+ if dangerous:
494
+ console.print("[bold red]⚠ Potentially dangerous shell steps detected![/bold red]")
495
+ for idx, cmd in dangerous:
496
+ console.print(f"[red] - Step {idx}: {cmd}[/red]")
497
+ return True
498
+ return False
499
+
500
+ def confirm_danger():
501
+ console.print("[bold red]Type 'proceed' to continue, or anything else to cancel.[/bold red]")
502
+ resp = Prompt.ask("[red]Confirmation[/red]", default="")
503
+ return resp.strip().lower() == "proceed"
504
+
505
+ def health_check(server: str) -> tuple[bool, str]:
506
+ base = server.rstrip("/")
507
+ try:
508
+ # fast check: GET /
509
+ r = requests.get(base, headers=auth_headers(), timeout=5)
510
+ if r.ok:
511
+ return True, "OK (/)"
512
+ except RequestException as e:
513
+ last_err = str(e)
514
+ else:
515
+ last_err = f"HTTP {r.status_code}: {r.text}"
516
+
517
+ # slow path: POST /infer with longer read timeout (Space cold start)
518
+ try:
519
+ r = requests.post(f"{base}/infer",
520
+ json={"prompt": "healthcheck"},
521
+ headers=auth_headers(),
522
+ timeout=(5, 90)) # (connect, read)
523
+ if r.ok:
524
+ return True, "OK (/infer)"
525
+ return False, f"HTTP {r.status_code}: {r.text}"
526
+ except RequestException as e:
527
+ return False, str(e) if str(e) else last_err
528
+
529
+ def api_infer(server: str, prompt: str, context: str | None = None) -> dict:
530
+ payload = {"prompt": prompt}
531
+ ctx_parts = []
532
+ if context:
533
+ ctx_parts.append(context)
534
+ try:
535
+ ctx_parts.append(f"CLIENT_OS: {platform.system().lower()}")
536
+ except Exception:
537
+ pass
538
+ if ctx_parts:
539
+ payload["context"] = "\n".join(ctx_parts)
540
+
541
+ r = requests.post(f"{server}/infer", json=payload,
542
+ headers=auth_headers(), timeout=3000)
543
+ r.raise_for_status()
544
+ return r.json()["plan"]
545
+
546
+ def api_execute(server: str, plan: dict) -> list:
547
+ r = requests.post(f"{server}/execute", json={"plan": plan},
548
+ headers=auth_headers(), timeout=1200)
549
+ r.raise_for_status()
550
+ return r.json()["results"]
551
+
552
+ def resolve_cmd_by_os(cmd_value):
553
+ # Same logic as server: accept a string or a {os: cmd} map
554
+ server_os = platform.system().lower()
555
+ if isinstance(cmd_value, str):
556
+ return cmd_value
557
+ if isinstance(cmd_value, dict):
558
+ c = cmd_value.get(server_os)
559
+ if c:
560
+ return c
561
+ if server_os in ("linux","darwin") and cmd_value.get("unix"):
562
+ return cmd_value["unix"]
563
+ if cmd_value.get("default"):
564
+ return cmd_value["default"]
565
+ for v in cmd_value.values():
566
+ if isinstance(v, str) and v.strip():
567
+ return v
568
+ raise ValueError("Invalid 'cmd' in shell step: expected string or {os: cmd} map.")
569
+
570
+ def safe_exec_python(code: str) -> str:
571
+ buf = io.StringIO()
572
+ with contextlib.redirect_stdout(buf):
573
+ try:
574
+ exec(code, {"__name__":"__main__"})
575
+ except Exception:
576
+ traceback.print_exc()
577
+ return buf.getvalue()
578
+
579
+ def api_gen(server: str, fmt: str, instruction: str, length: str = "medium") -> str:
580
+ r = requests.post(f"{server}/gen",
581
+ json={"format": fmt, "instruction": instruction, "length": length},
582
+ headers=auth_headers(), timeout=3000)
583
+ r.raise_for_status()
584
+ return (r.json() or {}).get("content","")
585
+
586
+ def local_execute_step(server: str, step: dict) -> dict:
587
+ t = (step.get("type") or "").lower()
588
+ started = time.time()
589
+ try:
590
+ if t == "mkdirs":
591
+ made = []
592
+ for d in step.get("paths", []) or []:
593
+ if not d: continue
594
+ os.makedirs(d, exist_ok=True)
595
+ made.append(d)
596
+ return {"type":"mkdirs","created":made,"ok":True, "duration_ms": int((time.time()-started)*1000)}
597
+
598
+ if t in {"write_file","edit_file","append_file"}:
599
+ path = step["path"]
600
+ content = step.get("content","")
601
+ mode = "w" if t != "append_file" else "a"
602
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
603
+ with open(path, mode, encoding="utf-8") as f:
604
+ f.write(content)
605
+ return {"type":t, "path":path, "mode":mode, "status":"ok",
606
+ "bytes":len(content.encode("utf-8")), "ok":True,
607
+ "duration_ms": int((time.time()-started)*1000)}
608
+
609
+ if t == "generate_file":
610
+ path = step["path"]
611
+ fmt = step.get("format","text")
612
+ instr= step.get("instruction","")
613
+ length = step.get("length","medium")
614
+ content = api_gen(server, fmt, instr, length)
615
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
616
+ with open(path,"w",encoding="utf-8") as f:
617
+ f.write(content)
618
+ return {"type":"generate_file","path":path,"status":"ok",
619
+ "bytes":len(content.encode("utf-8")), "ok":True,
620
+ "duration_ms": int((time.time()-started)*1000)}
621
+
622
+ # client (generic local handler)
623
+ if t == "rewrite_file":
624
+ path = step["path"]; instr = step.get("instruction",""); length = step.get("length","long")
625
+ current = Path(path).read_text(errors="ignore") if Path(path).exists() else ""
626
+ r = requests.post(f"{server}/assist/rewrite",
627
+ json={"instruction": instr, "current": current, "length": length},
628
+ headers=auth_headers(), timeout=3000)
629
+ r.raise_for_status()
630
+ new_content = r.json()["new_content"]
631
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
632
+ Path(path).write_text(new_content, encoding="utf-8")
633
+ return { # ← add this return
634
+ "type":"rewrite_file",
635
+ "path": path,
636
+ "bytes": len(new_content.encode("utf-8")),
637
+ "ok": True,
638
+ "duration_ms": int((time.time()-started)*1000)
639
+ }
640
+
641
+ # NEW: delegate respond_llm to the server so it doesn't echo instructions
642
+ if t == "respond_llm":
643
+ try:
644
+ r = requests.post(
645
+ f"{server}/execute",
646
+ json={"plan": {"steps": [step]}},
647
+ headers=auth_headers(),
648
+ timeout=600,
649
+ )
650
+ r.raise_for_status()
651
+ results = (r.json() or {}).get("results", [])
652
+ res = results[0] if results else {"type": "error", "error": "Empty result from server", "ok": False}
653
+ # Preserve errors; otherwise normalize to 'respond'
654
+ if res.get("type") == "error":
655
+ return {
656
+ "type": "respond",
657
+ "text": f"❌ Server error: {res.get('error')}\n\n{res.get('trace', '')}",
658
+ "ok": False,
659
+ "duration_ms": int((time.time() - started) * 1000),
660
+ }
661
+ if res.get("type") != "respond":
662
+ res = {"type": "respond", "text": res.get("text") or "", "ok": res.get("ok", True)}
663
+ res["duration_ms"] = int((time.time() - started) * 1000)
664
+ return res
665
+ except Exception as e:
666
+ # Fallback: show something rather than crashing
667
+ text = step.get("text") or step.get("instruction") or "Done."
668
+ return {
669
+ "type": "respond",
670
+ "text": text,
671
+ "ok": False,
672
+ "error": str(e),
673
+ "duration_ms": int((time.time() - started) * 1000),
674
+ }
675
+
676
+ # Keep plain 'respond' local (no LLM call needed)
677
+ if t == "respond":
678
+ text = step.get("text") or "Done."
679
+ return {"type": "respond", "text": text, "ok": True, "duration_ms": int((time.time() - started) * 1000)}
680
+
681
+ if t == "fs":
682
+ op = (step.get("op") or "").lower()
683
+ path = step.get("path")
684
+ if path: path = os.path.expanduser(path)
685
+ try:
686
+ if op == "list":
687
+ entries = sorted(os.listdir(path))
688
+ return {"type":"fs","op":op,"path":path,"entries":entries,"count":len(entries),"ok":True,
689
+ "duration_ms": int((time.time()-started)*1000)}
690
+ elif op == "read":
691
+ content = Path(path).read_text(errors="ignore")
692
+ return {"type":"fs","op":op,"path":path,"content":content,"bytes":len(content.encode()),"ok":True,
693
+ "duration_ms": int((time.time()-started)*1000)}
694
+ elif op == "write":
695
+ content = step.get("content","")
696
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
697
+ Path(path).write_text(content, encoding="utf-8")
698
+ return {"type":"fs","op":op,"path":path,"bytes":len(content.encode()),"ok":True,
699
+ "duration_ms": int((time.time()-started)*1000)}
700
+ elif op == "append":
701
+ content = step.get("content","")
702
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
703
+ with open(path,"a",encoding="utf-8") as f: f.write(content)
704
+ return {"type":"fs","op":op,"path":path,"bytes":len(content.encode()),"ok":True,
705
+ "duration_ms": int((time.time()-started)*1000)}
706
+ elif op == "mkdir":
707
+ Path(path).mkdir(parents=True, exist_ok=True)
708
+ return {"type":"fs","op":op,"path":path,"ok":True,
709
+ "duration_ms": int((time.time()-started)*1000)}
710
+ elif op == "remove":
711
+ p = Path(path)
712
+ if p.is_dir():
713
+ p.rmdir() # non-recursive by default; protects from accidents
714
+ else:
715
+ p.unlink(missing_ok=False)
716
+ return {"type":"fs","op":op,"path":path,"ok":True,
717
+ "duration_ms": int((time.time()-started)*1000)}
718
+ elif op == "move":
719
+ to = os.path.expanduser(step["to"])
720
+ Path(to).parent.mkdir(parents=True, exist_ok=True)
721
+ Path(path).replace(to)
722
+ return {"type":"fs","op":op,"path":path,"to":to,"ok":True,
723
+ "duration_ms": int((time.time()-started)*1000)}
724
+ elif op == "copy":
725
+ import shutil as _sh
726
+ to = os.path.expanduser(step["to"])
727
+ Path(to).parent.mkdir(parents=True, exist_ok=True)
728
+ _sh.copy2(path, to)
729
+ return {"type":"fs","op":op,"path":path,"to":to,"ok":True,
730
+ "duration_ms": int((time.time()-started)*1000)}
731
+ elif op == "exists":
732
+ return {"type":"fs","op":op,"path":path,"exists":Path(path).exists(),"ok":True,
733
+ "duration_ms": int((time.time()-started)*1000)}
734
+ elif op == "glob":
735
+ import glob as _glob
736
+ patt = step.get("pattern") or path
737
+ matches = sorted(_glob.glob(os.path.expanduser(patt)))
738
+ return {"type":"fs","op":op,"pattern":patt,"matches":matches,"count":len(matches),"ok":True,
739
+ "duration_ms": int((time.time()-started)*1000)}
740
+ else:
741
+ return {"type":"error","error":f"Unknown fs op '{op}'","ok":False,
742
+ "duration_ms": int((time.time()-started)*1000)}
743
+ except Exception as e:
744
+ return {"type":"error","error":str(e),"ok":False,
745
+ "duration_ms": int((time.time()-started)*1000)}
746
+
747
+ if t == "generate_tree":
748
+ base = step.get("base") or "."
749
+ files = step.get("files") or []
750
+ os.makedirs(base, exist_ok=True)
751
+ written = []
752
+ for f in files:
753
+ rel = f.get("path");
754
+ if not rel: continue
755
+ path = os.path.join(base, rel)
756
+ fmt = f.get("format","text")
757
+ instr = f.get("instruction","")
758
+ length= f.get("length","medium")
759
+ content = api_gen(server, fmt, instr, length)
760
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
761
+ with open(path,"w",encoding="utf-8") as fp:
762
+ fp.write(content)
763
+ written.append({"path": path, "bytes": len(content.encode("utf-8"))})
764
+ return {"type":"generate_tree","base":base,"written":written,"ok":True,
765
+ "duration_ms": int((time.time()-started)*1000)}
766
+
767
+ if t == "generate_large_file":
768
+ path = step["path"]
769
+ chunks = step.get("chunks") or []
770
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
771
+ total = 0
772
+ with open(path,"w",encoding="utf-8") as fp:
773
+ for ck in chunks:
774
+ instr = ck.get("instruction","")
775
+ length = ck.get("length","medium")
776
+ piece = api_gen(server, "text", instr, length)
777
+ fp.write(piece + ("\n" if not piece.endswith("\n") else ""))
778
+ total += len(piece.encode("utf-8"))
779
+ return {"type":"generate_large_file","path":path,"bytes":total,"chunks":len(chunks),"ok":True,
780
+ "duration_ms": int((time.time()-started)*1000)}
781
+
782
+ if t == "read_file":
783
+ path = step["path"]
784
+ with open(path,"r",errors="ignore") as f:
785
+ content = f.read()
786
+ return {"type":"read_file","path":path,"content":content,
787
+ "bytes":len(content.encode("utf-8")), "line_count":content.count("\n")+1 if content else 0,
788
+ "ok":True, "duration_ms": int((time.time()-started)*1000)}
789
+
790
+ if t == "list_dir":
791
+ path = step.get("path",".")
792
+ entries = sorted(os.listdir(path))
793
+ return {"type":"list_dir","path":path,"entries":entries,"count":len(entries),"ok":True,
794
+ "duration_ms": int((time.time()-started)*1000)}
795
+
796
+ if t == "python":
797
+ out = safe_exec_python(step.get("code",""))
798
+ ok_flag = ("Traceback (most recent call last):" not in out)
799
+ return {"type":"python","stdout":out,"ok":ok_flag, "duration_ms": int((time.time()-started)*1000)}
800
+
801
+ if t == "shell":
802
+ cmd = resolve_cmd_by_os(step["cmd"])
803
+ cwd = step.get("cwd") or None
804
+ timeout = float(step.get("timeout", 120))
805
+ env = os.environ.copy()
806
+ env.update(step.get("env", {}))
807
+ proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=cwd, timeout=timeout, env=env)
808
+ return {"type":"shell","cmd":cmd,"cwd":cwd,"stdout":proc.stdout,"stderr":proc.stderr,
809
+ "returncode":proc.returncode,"ok":(proc.returncode==0),
810
+ "duration_ms": int((time.time()-started)*1000)}
811
+
812
+ if t in {"respond","respond_llm"}:
813
+ text = step.get("text") or step.get("instruction") or "Done."
814
+ return {"type":"respond","text":text,"ok":True, "duration_ms": int((time.time()-started)*1000)}
815
+
816
+ return {"type":"error","error":f"Unknown step type {t}","ok":False, "duration_ms": int((time.time()-started)*1000)}
817
+
818
+ except Exception as e:
819
+ return {"type":"error","error":str(e),"ok":False, "duration_ms": int((time.time()-started)*1000)}
820
+
821
+ def local_execute(server: str, plan: dict) -> list[dict]:
822
+ results = []
823
+ for step in (plan.get("steps") or []):
824
+ res = local_execute_step(server, step)
825
+ results.append(res)
826
+ return results
827
+
828
+ # ──────────────────────────────────────────────────────────────────────────────
829
+ # Core flows
830
+ # ──────────────────────────────────────────────────────────────────────────────
831
+ def interactive_menu(plan: dict, *, dry_run: bool, server: str, session_dir: Path):
832
+ """Show plan and present menu actions."""
833
+ while True:
834
+ show_plan_table(plan)
835
+ console.print(
836
+ "[bold]Choose:[/bold] "
837
+ "[green][A][/green] Execute all β€’ "
838
+ "[green][S][/green] Step-by-step β€’ "
839
+ "[green][E][/green] Edit plan β€’ "
840
+ f"[green][D][/green] Toggle dry-run (now: {'ON' if dry_run else 'OFF'}) β€’ "
841
+ "[green][C][/green] Cancel"
842
+ )
843
+ choice = Prompt.ask("[yellow]Select[/yellow]", choices=["a","s","e","d","c"], default="a").lower()
844
+
845
+ if choice == "d":
846
+ dry_run = not dry_run
847
+ console.print(f"[cyan]Dry-run is now {'ON' if dry_run else 'OFF'}[/cyan]")
848
+ continue
849
+
850
+ if choice == "e":
851
+ edited = editor_edit_json(plan)
852
+ if edited is None:
853
+ console.print("[yellow]Keeping original plan.[/yellow]")
854
+ else:
855
+ plan = edited
856
+ (session_dir / "plan.edited.json").write_text(pretty_json(plan))
857
+ continue
858
+
859
+ if choice == "c":
860
+ console.print("[red]Cancelled.[/red]")
861
+ return
862
+
863
+ # Danger check once before any execution
864
+ if choice in ("a", "s") and warn_if_danger(plan):
865
+ if not confirm_danger():
866
+ console.print("[red]Aborted due to danger check.[/red]")
867
+ return
868
+
869
+ if dry_run:
870
+ console.print(Panel("DRY-RUN: No commands will be executed.", style="bold yellow"))
871
+ return
872
+
873
+ if choice == "a":
874
+ with Progress(
875
+ SpinnerColumn(),
876
+ TextColumn("[progress.description]{task.description}"),
877
+ TimeElapsedColumn(),
878
+ transient=True,
879
+ ) as progress:
880
+ progress.add_task(description="Executing all steps…", total=None)
881
+ try:
882
+ results = local_execute(server, plan)
883
+ except Exception as e:
884
+ console.print(f"[red]Execution failed:[/red] {e}")
885
+ return
886
+
887
+ (session_dir / "results.json").write_text(pretty_json(results))
888
+ show_result_panels(results)
889
+ return
890
+
891
+ if choice == "s":
892
+ stepwise_execute(server, plan, session_dir)
893
+ return
894
+
895
+
896
+ def stepwise_execute(server: str, plan: dict, session_dir: Path):
897
+ steps = plan.get("steps", [])
898
+ if not isinstance(steps, list) or not steps:
899
+ console.print("[yellow]No steps to execute.[/yellow]")
900
+ return
901
+
902
+ accumulated_results = []
903
+ for idx, step in enumerate(steps, 1):
904
+ # Compact, human summary of the step
905
+ stype = step.get("type", "unknown")
906
+ summary = stype
907
+ if stype == "shell":
908
+ summary = f"shell β€” [cyan]{step.get('cmd','')}[/cyan]"
909
+ elif stype == "read_file":
910
+ summary = f"read_file β€” {step.get('path','')}"
911
+ elif stype in {"write_file", "edit_file", "append_file"}:
912
+ summary = f"{stype} β€” {step.get('path','')}"
913
+ elif stype == "generate_file":
914
+ summary = f"generate_file β†’ {step.get('path','')} ({step.get('format','text')})"
915
+ elif stype == "generate_tree":
916
+ summary = f"generate_tree β€” base={step.get('base','.')}, files={len(step.get('files',[]))}"
917
+ elif stype == "generate_large_file":
918
+ summary = f"generate_large_file β€” {step.get('path','?')} ({len(step.get('chunks',[]))} chunks)"
919
+ elif stype == "mkdirs":
920
+ summary = f"mkdirs β€” {', '.join(step.get('paths', []) or [])}"
921
+ elif stype == "python":
922
+ code = step.get("code","").strip().splitlines()
923
+ summary = f"python β€” {code[0][:60]}…" if code else "python"
924
+ elif stype == "respond_llm":
925
+ inst = (step.get("instruction","") or "").strip()
926
+ summary = f"respond_llm β€” {inst[:60]}…" if len(inst) > 60 else f"respond_llm β€” {inst}"
927
+
928
+ console.print(Panel.fit(f"Step {idx}/{len(steps)} β€” {summary}", style="bold blue"))
929
+ console.print(Syntax(pretty_json(step), "json", theme="ansi_dark"))
930
+
931
+ # Per-step danger gate for shell
932
+ if stype == "shell" and any(
933
+ danger_check_shell(c) for c in _flatten_cmds_for_check(step.get("cmd",""))
934
+ ):
935
+ console.print("[bold red]Dangerous command detected for this step.[/bold red]")
936
+ if not confirm_danger():
937
+ console.print("[red]Skipping step.[/red]")
938
+ continue
939
+
940
+ console.print("[bold]Choose:[/bold] "
941
+ "[green][Y][/green] run β€’ "
942
+ "[green][E][/green] edit β€’ "
943
+ "[green][S][/green] skip β€’ "
944
+ "[green][Q][/green] quit all")
945
+ choice = Prompt.ask("[yellow]Select[/yellow]", choices=["y","e","s","q"], default="y").lower()
946
+
947
+ if choice == "q":
948
+ console.print("[red]Stopped by user.[/red]")
949
+ break
950
+
951
+ if choice == "e":
952
+ edited = editor_edit_json(step)
953
+ if edited is None:
954
+ console.print("[yellow]Keeping original step.[/yellow]")
955
+ else:
956
+ steps[idx-1] = edited
957
+ step = edited
958
+ (session_dir / f"step_{idx}.edited.json").write_text(pretty_json(step))
959
+ choice = Prompt.ask("[yellow]Run edited step now?[/yellow]", choices=["y","n"], default="y")
960
+ if choice.lower() != "y":
961
+ console.print("[yellow]Skipping.[/yellow]")
962
+ continue
963
+
964
+ if choice == "s":
965
+ console.print("[yellow]Skipped.[/yellow]")
966
+ continue
967
+
968
+ # Execute single-step by calling server with a one-step plan
969
+ single_plan = {"steps": [step]}
970
+ try:
971
+ res = local_execute_step(server, step)
972
+ results = [res]
973
+ accumulated_results.append(res)
974
+ (session_dir / f"step_{idx}.result.json").write_text(pretty_json(res))
975
+
976
+ console.rule(f"[bold magenta]Result β€” Step {idx}[/bold magenta]")
977
+ render_result(res)
978
+
979
+ except Exception as e:
980
+ console.print(f"[red]Step failed:[/red] {e}")
981
+ continue
982
+
983
+ if accumulated_results:
984
+ (session_dir / "results.stepwise.json").write_text(pretty_json(accumulated_results))
985
+ console.print(Panel(f"Done. {len(accumulated_results)} step(s) executed.", style="bold green"))
986
+ else:
987
+ console.print("[yellow]No steps executed.[/yellow]")
988
+
989
+ # ──────────────────────────────────────────────────────────────────────────────
990
+ # CLI
991
+ # ──────────────────────────────────────────────────────────────────────────────
992
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
993
+ @click.option("--server", default=DEFAULT_SERVER, show_default=True,
994
+ help="Server URL, e.g. http://127.0.0.1:5005")
995
+ @click.option("--context", "context_str", default="",
996
+ help="Optional context string to pass to /infer")
997
+ @click.option("--dry-run", is_flag=True, default=False,
998
+ help="Preview only, do not execute")
999
+ def main(server: str, context_str: str, dry_run: bool):
1000
+ ensure_dirs()
1001
+ cfg = load_config()
1002
+ cfg["server"] = server
1003
+ save_config(cfg)
1004
+
1005
+ try:
1006
+ _ = get_api_key()
1007
+ except Exception as e:
1008
+ console.print(f"[red]{e}[/red]")
1009
+ return
1010
+
1011
+ console.print(Panel.fit(f"Axis Client\n[green]{server}[/green]", style="bold blue"))
1012
+
1013
+ ok, msg = health_check(server) # includes Authorization header now
1014
+ if not ok:
1015
+ console.print(f"[red]Server health check failed:[/red] {msg}")
1016
+ return
1017
+
1018
+ session_dir = new_session_path()
1019
+ console.print(f"[dim]Session folder:[/dim] {session_dir}")
1020
+
1021
+ while True:
1022
+ try:
1023
+ user_in = Prompt.ask("\n[bold yellow]>>>[/bold yellow]").strip()
1024
+ if not user_in:
1025
+ continue
1026
+
1027
+ if user_in.lower() in ("exit", "quit", ":q"):
1028
+ console.print("[red]Goodbye.[/red]")
1029
+ break
1030
+
1031
+ # Local commands (client-side utilities)
1032
+ if user_in.startswith(":"):
1033
+ handled = handle_local_command(user_in, server, session_dir)
1034
+ if not handled:
1035
+ console.print("[yellow]Unknown client command.[/yellow]")
1036
+ continue
1037
+
1038
+ # Request a plan
1039
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=True) as prog:
1040
+ prog.add_task(description="Planning…", total=None)
1041
+ try:
1042
+ plan = api_infer(server, user_in, context_str or None)
1043
+ except Exception as e:
1044
+ console.print(f"[red]Plan failed:[/red] {e}")
1045
+ continue
1046
+
1047
+ # Save the prompt & plan
1048
+ (session_dir / "prompt.txt").write_text(user_in)
1049
+ (session_dir / "plan.json").write_text(pretty_json(plan))
1050
+
1051
+ interactive_menu(plan, dry_run=dry_run, server=server, session_dir=session_dir)
1052
+
1053
+ except KeyboardInterrupt:
1054
+ console.print("\n[red]Interrupted.[/red]")
1055
+ break
1056
+ except Exception as e:
1057
+ console.print(f"[red]Error:[/red] {e}")
1058
+ continue
1059
+
1060
+ # ──────────────────────────────────────────────────────────────────────────────
1061
+ # Client-side meta commands (prefixed with ':')
1062
+ # ──────────────────────────────────────────────────────────────────────────────
1063
+ def handle_local_command(cmdline: str, server: str, session_dir: Path) -> bool:
1064
+ """
1065
+ Supported:
1066
+ :server -> show server
1067
+ :server <URL> -> set new server
1068
+ :history -> list previous sessions
1069
+ :open <path> -> view a file with pager
1070
+ :clear -> clear screen
1071
+ :help -> show help
1072
+ """
1073
+ parts = shlex.split(cmdline)
1074
+ if not parts:
1075
+ return True
1076
+ cmd = parts[0].lower()
1077
+
1078
+ if cmd == ":server":
1079
+ if len(parts) == 1:
1080
+ console.print(f"[cyan]Current server:[/cyan] {server}")
1081
+ else:
1082
+ new_s = parts[1]
1083
+ cfg = load_config()
1084
+ cfg["server"] = new_s
1085
+ save_config(cfg)
1086
+ console.print(f"[green]Server updated:[/green] {new_s}")
1087
+ return True
1088
+
1089
+ if cmd == ":apikey":
1090
+ sub = parts[1].lower() if len(parts) > 1 else ""
1091
+ if sub == "set":
1092
+ new_key = getpass("New API key: ").strip()
1093
+ if new_key:
1094
+ write_local_key(new_key)
1095
+ console.print("[green]API key updated in key.json.[/green]")
1096
+ elif sub == "clear":
1097
+ try:
1098
+ KEY_FILE.unlink(missing_ok=True)
1099
+ console.print("[yellow]key.json removed. Next run will prompt.[/yellow]")
1100
+ except Exception as e:
1101
+ console.print(f"[red]Failed to remove key.json:[/red] {e}")
1102
+ else:
1103
+ console.print("[cyan]Usage:[/cyan] :apikey set | :apikey clear")
1104
+ return True
1105
+
1106
+ if cmd == ":history":
1107
+ rows = []
1108
+ for p in sorted(SESS_DIR.glob("*"), reverse=True)[:20]:
1109
+ stamp = p.name
1110
+ prompt = ""
1111
+ try:
1112
+ pt = (p / "prompt.txt").read_text().strip()
1113
+ prompt = (pt[:80] + "…") if len(pt) > 80 else pt
1114
+ except Exception:
1115
+ pass
1116
+ rows.append((stamp, prompt))
1117
+ if not rows:
1118
+ console.print("[yellow]No sessions yet.[/yellow]")
1119
+ return True
1120
+ t = Table(title="Recent Sessions", show_lines=False, box=ROUNDED)
1121
+ t.add_column("Session", style="cyan")
1122
+ t.add_column("Prompt", style="white")
1123
+ for s, pr in rows:
1124
+ t.add_row(s, pr)
1125
+ console.print(t)
1126
+ return True
1127
+
1128
+ if cmd == ":open" and len(parts) >= 2:
1129
+ path = Path(parts[1]).expanduser()
1130
+ if not path.exists():
1131
+ console.print(f"[red]No such file:[/red] {path}")
1132
+ return True
1133
+ try:
1134
+ pager(path.read_text())
1135
+ except Exception as e:
1136
+ console.print(f"[red]Failed to open:[/red] {e}")
1137
+ return True
1138
+
1139
+ if cmd == ":clear":
1140
+ console.clear()
1141
+ return True
1142
+
1143
+ if cmd == ":help":
1144
+ console.print(Panel.fit(
1145
+ "Meta commands:\n"
1146
+ " :server [URL] Show or set server URL\n"
1147
+ " :apikey set Save/replace API key to key.json\n"
1148
+ " :apikey clear Remove saved API key (prompt next run)\n"
1149
+ " :history Show last sessions\n"
1150
+ " :open <path> Page a local file\n"
1151
+ " :clear Clear the screen\n"
1152
+ " :help This help",
1153
+ title="Client Help", style="bold cyan"
1154
+ ))
1155
+ return True
1156
+
1157
+ return False
1158
+
1159
+ # ──────────────────────────────────────────────────────────────────────────────
1160
+ if __name__ == "__main__":
1161
+ main()