wizelit-cli 0.1.17__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wizelit_cli/__init__.py +35 -0
- wizelit_cli/agent_cli.py +267 -0
- wizelit_cli/api_config.py +77 -0
- wizelit_cli/checkpoint.py +44 -0
- wizelit_cli/config_paths.py +56 -0
- wizelit_cli/hitl.py +198 -0
- wizelit_cli/mcp_loader.py +89 -0
- wizelit_cli/mcp_settings_loader.py +175 -0
- wizelit_cli/profile_store.py +115 -0
- wizelit_cli/profile_sync.py +172 -0
- wizelit_cli/runner.py +217 -0
- wizelit_cli/secure_io.py +29 -0
- wizelit_cli/session.py +113 -0
- wizelit_cli/settings_loader.py +300 -0
- wizelit_cli/settings_types.py +16 -0
- wizelit_cli/shutdown.py +142 -0
- wizelit_cli/stream_events.py +81 -0
- wizelit_cli/stream_renderer.py +416 -0
- wizelit_cli/terminal_ui.py +120 -0
- wizelit_cli/util.py +33 -0
- wizelit_cli-0.1.17.dist-info/METADATA +89 -0
- wizelit_cli-0.1.17.dist-info/RECORD +25 -0
- wizelit_cli-0.1.17.dist-info/WHEEL +4 -0
- wizelit_cli-0.1.17.dist-info/entry_points.txt +2 -0
- wizelit_cli-0.1.17.dist-info/licenses/LICENSE +21 -0
wizelit_cli/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Interactive terminal CLI for the Wizelit deep agent (agent_cli-style)."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.17"
|
|
4
|
+
|
|
5
|
+
from wizelit_cli.agent_cli import main
|
|
6
|
+
from wizelit_cli.profile_sync import load_profile_layers, sync_cli_profile_to_disk
|
|
7
|
+
from wizelit_cli.profile_store import profile_path
|
|
8
|
+
from wizelit_cli.runner import run_interactive, run_once
|
|
9
|
+
from wizelit_cli.session import build_cli_agent
|
|
10
|
+
from wizelit_cli.settings_loader import resolve_cli_llm_config
|
|
11
|
+
from wizelit_cli.mcp_settings_loader import resolve_cli_mcp_servers
|
|
12
|
+
from wizelit_cli.settings_types import LlmRunConfig
|
|
13
|
+
from wizelit_cli.util import (
|
|
14
|
+
backend_install_root,
|
|
15
|
+
repo_thread_id,
|
|
16
|
+
resolve_cli_skills_dir,
|
|
17
|
+
resolve_cli_subagents_dir,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"LlmRunConfig",
|
|
22
|
+
"backend_install_root",
|
|
23
|
+
"build_cli_agent",
|
|
24
|
+
"load_profile_layers",
|
|
25
|
+
"main",
|
|
26
|
+
"profile_path",
|
|
27
|
+
"repo_thread_id",
|
|
28
|
+
"resolve_cli_llm_config",
|
|
29
|
+
"resolve_cli_mcp_servers",
|
|
30
|
+
"resolve_cli_skills_dir",
|
|
31
|
+
"resolve_cli_subagents_dir",
|
|
32
|
+
"run_interactive",
|
|
33
|
+
"run_once",
|
|
34
|
+
"sync_cli_profile_to_disk",
|
|
35
|
+
]
|
wizelit_cli/agent_cli.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Interactive terminal agent for Wizelit v2.
|
|
3
|
+
|
|
4
|
+
Examples:
|
|
5
|
+
cd packages/backend
|
|
6
|
+
uv run python scripts/agent_cli.py -i
|
|
7
|
+
uv run python scripts/agent_cli.py --repo /path/to/project -i
|
|
8
|
+
uv run python scripts/agent_cli.py "Summarize this repo"
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import signal
|
|
18
|
+
import sys
|
|
19
|
+
import uuid
|
|
20
|
+
from contextlib import AsyncExitStack
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from wizelit_cli.config_paths import load_cli_env
|
|
24
|
+
|
|
25
|
+
load_cli_env()
|
|
26
|
+
|
|
27
|
+
from wizelit_cli.checkpoint import close_checkpointer, open_checkpointer # noqa: E402
|
|
28
|
+
from wizelit_cli.runner import run_interactive, run_once # noqa: E402
|
|
29
|
+
from wizelit_cli.session import build_cli_agent, cli_user_id # noqa: E402
|
|
30
|
+
from wizelit_cli.profile_sync import load_profile_layers, sync_cli_profile_to_disk
|
|
31
|
+
from wizelit_cli.settings_loader import resolve_cli_llm_config # noqa: E402
|
|
32
|
+
from wizelit_cli.shutdown import ( # noqa: E402
|
|
33
|
+
run_cli_loop,
|
|
34
|
+
shutdown_cli_session_bounded,
|
|
35
|
+
)
|
|
36
|
+
from wizelit_cli.mcp_loader import disconnect_mcp_servers # noqa: E402
|
|
37
|
+
from wizelit_core.mcp_manager import mcp_manager # noqa: E402
|
|
38
|
+
from wizelit_cli.terminal_ui import stop_all_spinners # noqa: E402
|
|
39
|
+
from wizelit_cli.util import repo_thread_id # noqa: E402
|
|
40
|
+
from wizelit_core.llm_catalog import get_provider_models # noqa: E402
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _format_version() -> str:
|
|
44
|
+
import importlib.metadata
|
|
45
|
+
|
|
46
|
+
def _pkg_version(name: str) -> str:
|
|
47
|
+
try:
|
|
48
|
+
return importlib.metadata.version(name)
|
|
49
|
+
except importlib.metadata.PackageNotFoundError:
|
|
50
|
+
return "unknown"
|
|
51
|
+
|
|
52
|
+
cli_ver = _pkg_version("wizelit-cli")
|
|
53
|
+
core_ver = _pkg_version("wizelit-core")
|
|
54
|
+
return f"wizelit-agent {cli_ver} (wizelit-core {core_ver})"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _configure_logging() -> None:
|
|
58
|
+
if logging.getLogger().handlers:
|
|
59
|
+
return
|
|
60
|
+
level = getattr(logging, os.getenv("LOG_LEVEL", "WARNING").upper(), logging.WARNING)
|
|
61
|
+
logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _checkpoint_path(repo_path: Path) -> Path:
|
|
65
|
+
state_dir = repo_path / ".agent"
|
|
66
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
return state_dir / "cli_checkpoints.sqlite"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main(argv: list[str] | None = None) -> None:
|
|
71
|
+
_configure_logging()
|
|
72
|
+
|
|
73
|
+
parser = argparse.ArgumentParser(
|
|
74
|
+
description="Run the Wizelit deep agent in the terminal against a target repo.",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"-V",
|
|
78
|
+
"--version",
|
|
79
|
+
action="version",
|
|
80
|
+
version=_format_version(),
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument("prompt", nargs="*", help="Text to send to the agent.")
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"-i",
|
|
85
|
+
"--interactive",
|
|
86
|
+
action="store_true",
|
|
87
|
+
help="Interactive REPL (default when stdin is a TTY and no prompt given).",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--repo",
|
|
91
|
+
default=os.getcwd(),
|
|
92
|
+
help="Target repo path the agent operates on (default: CWD).",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--new-thread",
|
|
96
|
+
action="store_true",
|
|
97
|
+
help="Force a fresh conversation thread for this repo.",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--provider",
|
|
101
|
+
choices=list(get_provider_models().keys()),
|
|
102
|
+
help="LLM provider (bedrock, anthropic, google, openai).",
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument("--model", help="Model id for the selected provider.")
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--skills-dir",
|
|
107
|
+
help="Directory of skill modules (overrides bundled skills and WIZELIT_SKILLS_DIR).",
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--subagents-dir",
|
|
111
|
+
help="Directory of subagent .md specs (overrides bundled subagents and WIZELIT_SUBAGENTS_DIR).",
|
|
112
|
+
)
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--sync-profile",
|
|
115
|
+
action="store_true",
|
|
116
|
+
help="Fetch LLM + MCP settings from the backend and write ~/.wizelit/profile.json, then exit.",
|
|
117
|
+
)
|
|
118
|
+
args = parser.parse_args(argv)
|
|
119
|
+
|
|
120
|
+
if args.skills_dir:
|
|
121
|
+
os.environ["WIZELIT_SKILLS_DIR"] = str(Path(args.skills_dir).expanduser().resolve())
|
|
122
|
+
if args.subagents_dir:
|
|
123
|
+
os.environ["WIZELIT_SUBAGENTS_DIR"] = str(Path(args.subagents_dir).expanduser().resolve())
|
|
124
|
+
|
|
125
|
+
if args.sync_profile:
|
|
126
|
+
async def _sync_only() -> None:
|
|
127
|
+
path = await sync_cli_profile_to_disk()
|
|
128
|
+
print(f"Synced CLI profile to {path}")
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
asyncio.run(_sync_only())
|
|
132
|
+
except Exception as exc: # noqa: BLE001
|
|
133
|
+
print(f"error: profile sync failed: {exc}", file=sys.stderr)
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
sys.exit(0)
|
|
136
|
+
|
|
137
|
+
repo_path = Path(args.repo).resolve()
|
|
138
|
+
if not repo_path.is_dir():
|
|
139
|
+
print(f"error: --repo {repo_path} is not a directory", file=sys.stderr)
|
|
140
|
+
sys.exit(2)
|
|
141
|
+
|
|
142
|
+
interactive = args.interactive or (not args.prompt and sys.stdin.isatty())
|
|
143
|
+
interactive_tty = interactive and sys.stdin.isatty()
|
|
144
|
+
|
|
145
|
+
thread_id = repo_thread_id(repo_path)
|
|
146
|
+
if args.new_thread:
|
|
147
|
+
thread_id = f"{thread_id}-{uuid.uuid4().hex[:8]}"
|
|
148
|
+
checkpoint_db = _checkpoint_path(repo_path)
|
|
149
|
+
clean_exit = False
|
|
150
|
+
|
|
151
|
+
async def run_cli() -> None:
|
|
152
|
+
nonlocal clean_exit
|
|
153
|
+
conn, checkpointer = await open_checkpointer(checkpoint_db)
|
|
154
|
+
active_user_id: str | None = None
|
|
155
|
+
exit_process = False
|
|
156
|
+
prefer_local_profile = False
|
|
157
|
+
stale_cancel_retries = 0
|
|
158
|
+
run_exit_code = 0
|
|
159
|
+
try:
|
|
160
|
+
while True:
|
|
161
|
+
try:
|
|
162
|
+
profile_layers = await load_profile_layers(
|
|
163
|
+
prefer_local=prefer_local_profile
|
|
164
|
+
)
|
|
165
|
+
prefer_local_profile = False
|
|
166
|
+
try:
|
|
167
|
+
llm_config = await resolve_cli_llm_config(
|
|
168
|
+
provider_flag=args.provider,
|
|
169
|
+
model_flag=args.model,
|
|
170
|
+
interactive=interactive,
|
|
171
|
+
interactive_tty=interactive_tty,
|
|
172
|
+
profile_layers=profile_layers,
|
|
173
|
+
)
|
|
174
|
+
except ValueError as exc:
|
|
175
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
176
|
+
sys.exit(2)
|
|
177
|
+
|
|
178
|
+
print(
|
|
179
|
+
f"Using provider={llm_config.provider}, model={llm_config.model_id} "
|
|
180
|
+
f"(source={llm_config.settings_source})\n"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
config = {
|
|
184
|
+
"configurable": {"thread_id": thread_id},
|
|
185
|
+
"recursion_limit": llm_config.recursion_limit,
|
|
186
|
+
}
|
|
187
|
+
user_id = llm_config.user_identifier or cli_user_id()
|
|
188
|
+
active_user_id = user_id
|
|
189
|
+
|
|
190
|
+
async with AsyncExitStack() as stack:
|
|
191
|
+
agent = await asyncio.shield(
|
|
192
|
+
build_cli_agent(
|
|
193
|
+
repo_path=repo_path,
|
|
194
|
+
checkpointer=checkpointer,
|
|
195
|
+
stack=stack,
|
|
196
|
+
llm_config=llm_config,
|
|
197
|
+
thread_id=thread_id,
|
|
198
|
+
profile_layers=profile_layers,
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if interactive or not args.prompt:
|
|
203
|
+
result = await run_interactive(agent, config)
|
|
204
|
+
if result == "RELOAD":
|
|
205
|
+
print("Reloading agent and MCP...", flush=True)
|
|
206
|
+
try:
|
|
207
|
+
await disconnect_mcp_servers(user_id)
|
|
208
|
+
except asyncio.CancelledError:
|
|
209
|
+
mcp_manager.drop_user(user_id)
|
|
210
|
+
await asyncio.sleep(0.75)
|
|
211
|
+
prefer_local_profile = True
|
|
212
|
+
stale_cancel_retries = 0
|
|
213
|
+
continue
|
|
214
|
+
else:
|
|
215
|
+
await run_once(agent, config, " ".join(args.prompt))
|
|
216
|
+
stale_cancel_retries = 0
|
|
217
|
+
exit_process = True
|
|
218
|
+
break
|
|
219
|
+
except asyncio.CancelledError:
|
|
220
|
+
# MCP stream teardown can cancel the next loop iteration briefly.
|
|
221
|
+
if stale_cancel_retries < 12:
|
|
222
|
+
stale_cancel_retries += 1
|
|
223
|
+
prefer_local_profile = True
|
|
224
|
+
await asyncio.sleep(0.15)
|
|
225
|
+
continue
|
|
226
|
+
exit_process = True
|
|
227
|
+
raise
|
|
228
|
+
except BaseException:
|
|
229
|
+
exit_process = True
|
|
230
|
+
run_exit_code = 1
|
|
231
|
+
raise
|
|
232
|
+
finally:
|
|
233
|
+
if exit_process:
|
|
234
|
+
clean_exit = True
|
|
235
|
+
await shutdown_cli_session_bounded(
|
|
236
|
+
user_id=active_user_id,
|
|
237
|
+
conn=conn,
|
|
238
|
+
exit_code=run_exit_code,
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
stop_all_spinners()
|
|
242
|
+
|
|
243
|
+
def _sigint_handler(_signum: int, _frame: object) -> None:
|
|
244
|
+
stop_all_spinners()
|
|
245
|
+
raise KeyboardInterrupt
|
|
246
|
+
|
|
247
|
+
previous_sigint = signal.getsignal(signal.SIGINT)
|
|
248
|
+
if sys.stdin.isatty():
|
|
249
|
+
signal.signal(signal.SIGINT, _sigint_handler)
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
run_cli_loop(run_cli())
|
|
253
|
+
except KeyboardInterrupt:
|
|
254
|
+
print("\nInterrupted.", file=sys.stderr)
|
|
255
|
+
sys.exit(130)
|
|
256
|
+
except asyncio.CancelledError:
|
|
257
|
+
if not clean_exit:
|
|
258
|
+
print("\nInterrupted.", file=sys.stderr)
|
|
259
|
+
sys.exit(130)
|
|
260
|
+
finally:
|
|
261
|
+
stop_all_spinners()
|
|
262
|
+
if sys.stdin.isatty():
|
|
263
|
+
signal.signal(signal.SIGINT, previous_sigint)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
main()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Shared backend API configuration for the terminal CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_DEFAULT_API_URL = "http://localhost:8000"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
16
|
+
raw = os.getenv(name)
|
|
17
|
+
if raw is None:
|
|
18
|
+
return default
|
|
19
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def api_url() -> str:
|
|
23
|
+
return (os.getenv("WIZELIT_API_URL") or _DEFAULT_API_URL).rstrip("/")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def api_token() -> str | None:
|
|
27
|
+
token = (os.getenv("WIZELIT_IDE_TOKEN") or "").strip()
|
|
28
|
+
return token or None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def use_api_settings() -> bool:
|
|
32
|
+
return _env_bool("WIZELIT_CLI_USE_API_SETTINGS", True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def allow_insecure_http() -> bool:
|
|
36
|
+
"""Opt-in for sending credentials to remote plain-HTTP APIs."""
|
|
37
|
+
return _env_bool("WIZELIT_ALLOW_INSECURE", False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_loopback_host(host: str | None) -> bool:
|
|
41
|
+
if not host:
|
|
42
|
+
return False
|
|
43
|
+
normalized = host.strip("[]").lower()
|
|
44
|
+
if normalized == "localhost":
|
|
45
|
+
return True
|
|
46
|
+
try:
|
|
47
|
+
return ipaddress.ip_address(normalized).is_loopback
|
|
48
|
+
except ValueError:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_insecure_remote_http(base_url: str) -> bool:
|
|
53
|
+
"""True when ``base_url`` is plain HTTP to a non-loopback host."""
|
|
54
|
+
parsed = urlparse(base_url)
|
|
55
|
+
if parsed.scheme != "http":
|
|
56
|
+
return False
|
|
57
|
+
return not _is_loopback_host(parsed.hostname)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def bearer_auth_headers(base_url: str, token: str | None) -> dict[str, str]:
|
|
61
|
+
"""Build Authorization headers, refusing cleartext token to remote hosts."""
|
|
62
|
+
if not token:
|
|
63
|
+
return {}
|
|
64
|
+
if is_insecure_remote_http(base_url):
|
|
65
|
+
if allow_insecure_http():
|
|
66
|
+
logger.warning(
|
|
67
|
+
"Sending IDE bearer token to remote plain HTTP API %s "
|
|
68
|
+
"(WIZELIT_ALLOW_INSECURE=1). Prefer https://.",
|
|
69
|
+
base_url,
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Refusing to send IDE token over plain HTTP to non-loopback host "
|
|
74
|
+
f"({base_url}). Use https:// or set WIZELIT_ALLOW_INSECURE=1 to "
|
|
75
|
+
"override (not recommended)."
|
|
76
|
+
)
|
|
77
|
+
return {"Authorization": f"Bearer {token}"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""SQLite checkpoint helpers for the terminal CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def open_checkpointer(db_path: Path) -> tuple[Any, Any]:
|
|
14
|
+
"""Open an AsyncSqliteSaver backed by aiosqlite. Returns (conn, saver)."""
|
|
15
|
+
import aiosqlite
|
|
16
|
+
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
17
|
+
|
|
18
|
+
conn = await aiosqlite.connect(str(db_path))
|
|
19
|
+
return conn, AsyncSqliteSaver(conn)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _force_close_sqlite(conn: Any) -> None:
|
|
23
|
+
"""Stop aiosqlite without awaiting close() (avoids wedging the event loop on exit)."""
|
|
24
|
+
try:
|
|
25
|
+
conn.stop()
|
|
26
|
+
except Exception: # noqa: BLE001
|
|
27
|
+
pass
|
|
28
|
+
thread = getattr(conn, "_thread", None)
|
|
29
|
+
if thread is not None and thread.is_alive():
|
|
30
|
+
thread.join(timeout=1.0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def close_checkpointer(conn: Any, *, force: bool = False) -> None:
|
|
34
|
+
"""Close aiosqlite before the event loop stops."""
|
|
35
|
+
if conn is None:
|
|
36
|
+
return
|
|
37
|
+
if force:
|
|
38
|
+
_force_close_sqlite(conn)
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
await asyncio.wait_for(conn.close(), timeout=2.0)
|
|
42
|
+
except Exception as exc: # noqa: BLE001
|
|
43
|
+
logger.debug("sqlite close: %s", exc)
|
|
44
|
+
_force_close_sqlite(conn)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Config and install paths for the distributed terminal CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cli_config_dir() -> Path:
|
|
10
|
+
"""User config directory (default ``~/.wizelit`` or ``$XDG_CONFIG_HOME/wizelit``)."""
|
|
11
|
+
if raw := os.getenv("WIZELIT_CONFIG_DIR"):
|
|
12
|
+
return Path(raw).expanduser().resolve()
|
|
13
|
+
if xdg := os.getenv("XDG_CONFIG_HOME"):
|
|
14
|
+
return (Path(xdg).expanduser() / "wizelit").resolve()
|
|
15
|
+
return (Path.home() / ".wizelit").resolve()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def dev_backend_root() -> Path:
|
|
19
|
+
"""Monorepo ``packages/backend`` when developing from a checkout."""
|
|
20
|
+
# wizelit_cli/config_paths.py → packages/cli/wizelit_cli → packages/backend
|
|
21
|
+
candidate = Path(__file__).resolve().parents[2] / "backend"
|
|
22
|
+
if (candidate / "skills").is_dir():
|
|
23
|
+
return candidate
|
|
24
|
+
return Path(__file__).resolve().parents[2]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def backend_install_root() -> Path:
|
|
28
|
+
"""Root containing bundled ``skills/`` and ``subagents/`` (wheel or source tree)."""
|
|
29
|
+
if env_root := os.getenv("WIZELIT_BACKEND_ROOT"):
|
|
30
|
+
return Path(env_root).expanduser().resolve()
|
|
31
|
+
|
|
32
|
+
import wizelit_core
|
|
33
|
+
|
|
34
|
+
site_root = Path(wizelit_core.__file__).resolve().parent.parent
|
|
35
|
+
if (site_root / "skills").is_dir():
|
|
36
|
+
return site_root
|
|
37
|
+
|
|
38
|
+
return dev_backend_root()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_cli_env() -> None:
|
|
42
|
+
"""Load CLI environment: user ``~/.wizelit/.env`` first, then dev ``packages/backend/.env``."""
|
|
43
|
+
from dotenv import load_dotenv
|
|
44
|
+
|
|
45
|
+
from wizelit_cli.secure_io import ensure_private_dir
|
|
46
|
+
|
|
47
|
+
config_dir = cli_config_dir()
|
|
48
|
+
ensure_private_dir(config_dir)
|
|
49
|
+
user_env = config_dir / ".env"
|
|
50
|
+
if user_env.is_file():
|
|
51
|
+
load_dotenv(user_env)
|
|
52
|
+
|
|
53
|
+
dev_env = dev_backend_root() / ".env"
|
|
54
|
+
if dev_env.is_file():
|
|
55
|
+
# Dev checkout defaults; user config values already loaded take precedence.
|
|
56
|
+
load_dotenv(dev_env, override=False)
|
wizelit_cli/hitl.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Human-in-the-loop prompts for the terminal CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from wizelit_cli.terminal_ui import style
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _format_hitl_action(action_request: dict) -> str:
|
|
13
|
+
name = action_request.get("name", "tool")
|
|
14
|
+
args = action_request.get("args", {})
|
|
15
|
+
description = action_request.get("description", "")
|
|
16
|
+
|
|
17
|
+
if name == "write_file" and isinstance(args, dict):
|
|
18
|
+
content_value = args.get("content", "")
|
|
19
|
+
content_len = len(content_value) if isinstance(content_value, str) else len(str(content_value))
|
|
20
|
+
safe_args = {
|
|
21
|
+
"file_path": args.get("file_path"),
|
|
22
|
+
"content_length": content_len,
|
|
23
|
+
}
|
|
24
|
+
else:
|
|
25
|
+
safe_args = args
|
|
26
|
+
|
|
27
|
+
args_text = json.dumps(safe_args, indent=2, default=str)
|
|
28
|
+
parts = [
|
|
29
|
+
style("yellow", f"[approval required] {name}"),
|
|
30
|
+
f"Args:\n{args_text}",
|
|
31
|
+
]
|
|
32
|
+
if description:
|
|
33
|
+
if name == "write_file":
|
|
34
|
+
# Middleware default description echoes full args, including content.
|
|
35
|
+
# Keep it concise for CLI HITL prompts.
|
|
36
|
+
description = "Tool execution requires approval."
|
|
37
|
+
parts.append(f"Reason:\n{description}")
|
|
38
|
+
return "\n".join(parts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def collect_hitl_decisions(request: dict) -> list[dict]:
|
|
42
|
+
"""Prompt for human approval decisions for a middleware HITL request."""
|
|
43
|
+
action_requests = request.get("action_requests", [])
|
|
44
|
+
review_configs = request.get("review_configs", [])
|
|
45
|
+
decisions: list[dict] = []
|
|
46
|
+
|
|
47
|
+
if not isinstance(action_requests, list) or not isinstance(review_configs, list):
|
|
48
|
+
return [{"type": "reject", "message": "Invalid HITL request payload."}]
|
|
49
|
+
|
|
50
|
+
if not action_requests:
|
|
51
|
+
# LangGraph accepts Command(resume={"decisions": []}), but an interrupt
|
|
52
|
+
# with no actions is spurious — return [] so callers skip resume.
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
for idx, action in enumerate(action_requests):
|
|
56
|
+
cfg = review_configs[idx] if idx < len(review_configs) else {}
|
|
57
|
+
allowed = set(cfg.get("allowed_decisions") or [])
|
|
58
|
+
action_name = str(action.get("name", ""))
|
|
59
|
+
action_args = action.get("args", {})
|
|
60
|
+
|
|
61
|
+
if action_name == "write_file" and isinstance(action_args, dict):
|
|
62
|
+
target_path = action_args.get("file_path")
|
|
63
|
+
if isinstance(target_path, str) and target_path.strip():
|
|
64
|
+
target = Path(target_path).expanduser()
|
|
65
|
+
if target.exists():
|
|
66
|
+
print("", flush=True)
|
|
67
|
+
print(style("bold", f"HITL Review {idx + 1}/{len(action_requests)}"), flush=True)
|
|
68
|
+
print(_format_hitl_action(action), flush=True)
|
|
69
|
+
print(
|
|
70
|
+
style("yellow", f"Target already exists: {target}"),
|
|
71
|
+
flush=True,
|
|
72
|
+
)
|
|
73
|
+
print("Choose action: [e]dit existing / [n]ew path / [c]ancel (default: c)", flush=True)
|
|
74
|
+
|
|
75
|
+
while True:
|
|
76
|
+
existing_choice = (await asyncio.to_thread(input, "> ")).strip().lower()
|
|
77
|
+
if existing_choice in {"", "c", "cancel"}:
|
|
78
|
+
decisions.append(
|
|
79
|
+
{
|
|
80
|
+
"type": "reject",
|
|
81
|
+
"message": (
|
|
82
|
+
f"User cancelled write_file because the target path already exists: {target}. "
|
|
83
|
+
"Do not write to this path unless the user explicitly asks again."
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
break
|
|
88
|
+
if existing_choice in {"e", "edit"}:
|
|
89
|
+
decisions.append(
|
|
90
|
+
{
|
|
91
|
+
"type": "reject",
|
|
92
|
+
"message": (
|
|
93
|
+
f"Target file already exists: {target}. "
|
|
94
|
+
"Read the existing file and use edit_file instead of write_file."
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
break
|
|
99
|
+
if existing_choice in {"n", "new"}:
|
|
100
|
+
while True:
|
|
101
|
+
new_path = (
|
|
102
|
+
await asyncio.to_thread(
|
|
103
|
+
input, "Enter new file path (or 'c' to cancel): "
|
|
104
|
+
)
|
|
105
|
+
).strip()
|
|
106
|
+
if not new_path:
|
|
107
|
+
print("Please provide a non-empty path.", flush=True)
|
|
108
|
+
continue
|
|
109
|
+
if new_path.lower() in {"c", "cancel"}:
|
|
110
|
+
decisions.append(
|
|
111
|
+
{
|
|
112
|
+
"type": "reject",
|
|
113
|
+
"message": (
|
|
114
|
+
"User cancelled while selecting a new path "
|
|
115
|
+
f"for existing target: {target}."
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
break
|
|
120
|
+
candidate = Path(new_path).expanduser()
|
|
121
|
+
if candidate.exists():
|
|
122
|
+
print(
|
|
123
|
+
style(
|
|
124
|
+
"yellow",
|
|
125
|
+
f"Path already exists: {candidate}. Please choose another path.",
|
|
126
|
+
),
|
|
127
|
+
flush=True,
|
|
128
|
+
)
|
|
129
|
+
continue
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
if decisions and decisions[-1].get("type") == "reject":
|
|
133
|
+
break
|
|
134
|
+
if "edit" in allowed:
|
|
135
|
+
edited_args = dict(action_args)
|
|
136
|
+
edited_args["file_path"] = new_path
|
|
137
|
+
decisions.append(
|
|
138
|
+
{
|
|
139
|
+
"type": "edit",
|
|
140
|
+
"edited_action": {
|
|
141
|
+
"name": action_name,
|
|
142
|
+
"args": edited_args,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
decisions.append(
|
|
148
|
+
{
|
|
149
|
+
"type": "reject",
|
|
150
|
+
"message": (
|
|
151
|
+
f"Target file already exists: {target}. "
|
|
152
|
+
f"Write the content to this new path instead: {new_path}."
|
|
153
|
+
),
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
break
|
|
157
|
+
print("Please enter e, n, or c.", flush=True)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# POC: support approve/reject only. If a tool is configured differently,
|
|
161
|
+
# prefer safe behavior (reject) over guessing an unsupported decision.
|
|
162
|
+
if "approve" not in allowed and "reject" in allowed:
|
|
163
|
+
decisions.append(
|
|
164
|
+
{
|
|
165
|
+
"type": "reject",
|
|
166
|
+
"message": "CLI POC only supports approve/reject; approve not allowed.",
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
continue
|
|
170
|
+
if "approve" not in allowed:
|
|
171
|
+
decisions.append({"type": "reject", "message": "CLI POC cannot handle this decision type."})
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
print("", flush=True)
|
|
175
|
+
print(style("bold", f"HITL Review {idx + 1}/{len(action_requests)}"), flush=True)
|
|
176
|
+
print(_format_hitl_action(action), flush=True)
|
|
177
|
+
|
|
178
|
+
prompt = "Approve? [a]pprove/[r]eject (default: r): " if "reject" in allowed else "Approve? [a]: "
|
|
179
|
+
while True:
|
|
180
|
+
choice = (await asyncio.to_thread(input, prompt)).strip().lower()
|
|
181
|
+
if choice in {"", "r", "reject"} and "reject" in allowed:
|
|
182
|
+
reason = await asyncio.to_thread(
|
|
183
|
+
input,
|
|
184
|
+
"Optional rejection reason (enter to skip): ",
|
|
185
|
+
)
|
|
186
|
+
reason = reason.strip()
|
|
187
|
+
payload = {"type": "reject"}
|
|
188
|
+
if reason:
|
|
189
|
+
payload["message"] = reason
|
|
190
|
+
decisions.append(payload)
|
|
191
|
+
break
|
|
192
|
+
if choice in {"a", "approve"}:
|
|
193
|
+
decisions.append({"type": "approve"})
|
|
194
|
+
break
|
|
195
|
+
print("Please enter 'a' to approve or 'r' to reject.", flush=True)
|
|
196
|
+
|
|
197
|
+
return decisions
|
|
198
|
+
|