Upload client.py
Browse files- 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()
|