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.
@@ -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
+ ]
@@ -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
+