""" aica CLI 入口模块 — §4.21 Typer CLI 框架 + httpx HTTP 客户端 + Rich 终端美化。 命令名称: aica (AI Code Assistant 缩写,4 字符,易输入) 退出码: 0 — 成功完成 1 — 通用错误 2 — 参数错误 3 — 连接错误 4 — 认证错误 130 — SIGINT 中断 """ import json import ipaddress import os import signal import sys from pathlib import Path from typing import Optional from enum import Enum from importlib.metadata import version as pkg_version, PackageNotFoundError from urllib.parse import urlsplit import typer import httpx from rich.console import Console from rich.markdown import Markdown from .client import AicaClient, StreamEvent from .session import SessionCache app = typer.Typer( name="aica", help="AI Code Assistant CLI — 通过管道和脚本调用 AI 编程助手", no_args_is_help=True, add_completion=True, ) console = Console(stderr=True) # 元信息输出到 stderr stdout_console = Console() # LLM 内容输出到 stdout def _version_callback(value: bool): if value: try: v = pkg_version("zhikuncode-python-service") except PackageNotFoundError: v = "1.0.0" print(f"aica {v}") raise typer.Exit() class OutputFormat(str, Enum): text = "text" json = "json" stream_json = "stream-json" class PermissionMode(str, Enum): dont_ask = "dont_ask" default = "default" plan = "plan" accept_edits = "accept_edits" auto_approve = "auto_approve" class EffortLevel(str, Enum): low = "low" medium = "medium" high = "high" max = "max" def _is_loopback_server(server: str) -> bool: """Return whether the configured server can use this process' paths.""" parsed = urlsplit(server if "://" in server else f"//{server}") hostname = parsed.hostname if not hostname: return False if hostname.lower() == "localhost": return True try: return ipaddress.ip_address(hostname).is_loopback except ValueError: # Deliberately do not resolve DNS names. A private-looking hostname does # not prove that the CLI and backend share a filesystem. return False def _find_project_id( projects: list[dict], workspace_root: str, ) -> Optional[str]: """Find a Project whose persisted workspace path exactly matches cwd.""" for project in projects: if ( isinstance(project, dict) and project.get("workspaceRoot") == workspace_root and project.get("id") ): return str(project["id"]) return None def _resolve_local_project( client: AicaClient, working_dir: str, ) -> Optional[str]: """Reuse or create the Project representing a local CLI workspace.""" workspace_path = Path(working_dir).expanduser().resolve() workspace_root = str(workspace_path) try: projects = client.list_projects() project_id = _find_project_id(projects, workspace_root) if project_id: return project_id try: created = client.create_project( workspace_path.name or "root", workspace_root, ) created_id = created.get("id") if not created_id: console.print( "[red]Error: Project API returned no Project ID[/red]" ) raise typer.Exit(code=1) return str(created_id) except httpx.HTTPStatusError as error: if error.response.status_code != 409: raise # Another CLI may have registered the same canonical path between # our list and create calls. Re-list once and reuse that Project. projects = client.list_projects() project_id = _find_project_id(projects, workspace_root) if project_id: return project_id raise except httpx.HTTPStatusError as error: status = error.response.status_code if status in (400, 403, 404): console.print( "[red]Error: the selected working directory cannot be " f"authorized (HTTP {status})[/red]" ) raise typer.Exit(code=1) raise def _handle_sigint(signum, frame): """Ctrl+C → exit 130""" console.print("\n[dim]Interrupted[/dim]") sys.exit(130) @app.command() def main( prompt: Optional[str] = typer.Argument(None, help="查询内容"), version: bool = typer.Option( False, "--version", "-V", callback=_version_callback, is_eager=True, help="显示版本号"), # 输出控制 output_format: OutputFormat = typer.Option( OutputFormat.text, "--output-format", "-f", help="输出格式"), input_format: str = typer.Option( "text", "--input-format", help="输入格式: text | stream-json"), include_partial_messages: bool = typer.Option( False, "--include-partial-messages", help="包含部分消息块(仅stream-json)"), verbose: bool = typer.Option(False, "--verbose", help="详细输出"), quiet: bool = typer.Option(False, "--quiet", "-q", help="静默模式"), # 模型与行为 model: Optional[str] = typer.Option(None, "--model", "-m", help="指定模型"), effort: Optional[EffortLevel] = typer.Option( None, "--effort", help="推理努力等级"), fallback_model: Optional[str] = typer.Option( None, "--fallback-model", help="主模型过载时降级模型"), system_prompt: Optional[str] = typer.Option( None, "--system-prompt", help="替换系统提示"), system_prompt_file: Optional[Path] = typer.Option( None, "--system-prompt-file", help="从文件读取系统提示"), append_system_prompt: Optional[str] = typer.Option( None, "--append-system-prompt", help="追加系统提示"), max_turns: Optional[int] = typer.Option( None, "--max-turns", help="最大轮次"), max_budget: Optional[float] = typer.Option( None, "--max-budget", help="当前不支持金额预算;设置后服务端将拒绝请求"), json_schema: Optional[str] = typer.Option( None, "--json-schema", help="JSON Schema 约束输出结构"), # 权限 permission_mode: Optional[PermissionMode] = typer.Option( None, "--permission-mode", help="权限模式;未指定时新建默认完全访问,续接沿用会话权限"), # 工具 allowed_tools: Optional[str] = typer.Option( None, "--allowed-tools", help="工具白名单(逗号分隔)"), disallowed_tools: Optional[str] = typer.Option( None, "--disallowed-tools", help="工具黑名单(逗号分隔)"), tools: Optional[str] = typer.Option( None, "--tools", help="指定可用工具集(逗号分隔)"), # 会话 continue_session: bool = typer.Option( False, "--continue", "-c", help="继续上次会话"), resume: Optional[str] = typer.Option( None, "--resume", "-r", help="恢复指定会话"), session_id: Optional[str] = typer.Option( None, "--session-id", help="使用指定会话 ID"), project_id: Optional[str] = typer.Option( None, "--project-id", help="新建会话时使用的服务端 Project ID"), fork_session: bool = typer.Option( False, "--fork-session", help="恢复时创建新会话 ID"), name: Optional[str] = typer.Option( None, "--name", "-n", help="会话显示名称"), no_session: bool = typer.Option( False, "--no-session", help="不持久化会话"), # 连接 server: str = typer.Option( "http://localhost:8080", "--server", "-s", help="后端地址"), token: Optional[str] = typer.Option( None, "--token", help="认证 Token"), timeout: int = typer.Option(300, "--timeout", help="超时秒数"), # MCP mcp_config: Optional[str] = typer.Option( None, "--mcp-config", help="MCP 配置文件"), # 工作目录 working_dir: Optional[str] = typer.Option( None, "--working-dir", "-w", help="本地后端的新会话 Project 目录;远程后端请使用 --project-id"), ): """ AI Code Assistant — 命令行查询接口。 支持管道输入: cat file.py | aica "review this" 支持结构化输出: aica -f json "query" | jq '.result' """ signal.signal(signal.SIGINT, _handle_sigint) # 1. 读取 stdin(如果是管道) stdin_content = None if not sys.stdin.isatty(): stdin_content = sys.stdin.read(1024 * 1024) # 最大 1MB if len(stdin_content) >= 1024 * 1024: console.print("[yellow]Warning: stdin truncated at 1MB[/yellow]") # 2. 从文件读取系统提示 effective_system_prompt = system_prompt if system_prompt_file and not system_prompt: effective_system_prompt = system_prompt_file.read_text(encoding="utf-8") # 3. 验证输入 if not prompt and not stdin_content: console.print("[red]Error: No prompt or stdin input[/red]") raise typer.Exit(code=2) # 4. 解析权限模式 # 权限模式只控制授权决策;任何模式都不能绕过系统安全不变量。 perm = permission_mode.value.upper() if permission_mode is not None else None if working_dir is not None and not _is_loopback_server(server): console.print( "[red]Error: --working-dir can only authorize a directory " "when the backend is on localhost; use --project-id for a " "remote backend[/red]" ) raise typer.Exit(code=2) if working_dir is not None and project_id is not None: console.print( "[red]Error: --working-dir cannot be combined " "with --project-id[/red]" ) raise typer.Exit(code=2) # 5. 解析会话 cache = SessionCache() wd = str(Path(working_dir or os.getcwd()).expanduser().resolve()) resolved_sid = session_id if continue_session and not resolved_sid: resolved_sid = cache.get_last_session(wd) elif resume and not resolved_sid: resolved_sid = resume if resolved_sid and project_id: console.print( "[red]Error: --project-id cannot be combined " "with an existing session[/red]") raise typer.Exit(code=2) # 6. 创建客户端,按需解析本地 Project,然后执行查询 client = AicaClient(server=server, token=token, timeout=timeout) response_sid = None try: effective_project_id = project_id if ( working_dir is not None and not resolved_sid and not effective_project_id and _is_loopback_server(server) ): effective_project_id = _resolve_local_project(client, wd) request_body: dict = { "prompt": prompt, "model": model, "effort": effort.value if effort else None, "fallbackModel": fallback_model, "systemPrompt": effective_system_prompt, "appendSystemPrompt": append_system_prompt, "permissionMode": perm, "maxTurns": max_turns or 99, "maxBudgetUsd": max_budget, "allowedTools": allowed_tools.split(",") if allowed_tools else None, "disallowedTools": ( disallowed_tools.split(",") if disallowed_tools else None ), "tools": tools.split(",") if tools else None, "projectId": effective_project_id, "sessionId": resolved_sid, "forkSession": fork_session or None, "name": name, "timeoutSeconds": timeout, "jsonSchema": json_schema, "includePartialMessages": include_partial_messages or None, "context": {"stdin": stdin_content} if stdin_content else None, } request_body = { key: value for key, value in request_body.items() if value is not None } if output_format == OutputFormat.stream_json: response_sid = _stream_query(client, request_body, verbose) elif output_format == OutputFormat.json: response_sid = _sync_query_json(client, request_body) else: response_sid = _sync_query_text(client, request_body, verbose, quiet) except httpx.ConnectError: console.print(f"[red]Error: Backend not reachable at {server}[/red]") raise typer.Exit(code=3) except httpx.HTTPStatusError as e: if e.response.status_code == 409: try: body = e.response.json() except ValueError: body = {} error = body.get("error") if isinstance(body, dict) else None if isinstance(error, dict) and error.get("code") == "PERMISSION_MODE_MISMATCH": console.print("[red]Error: 指定权限与会话权限不一致;请先在页面修改会话权限,或省略 --permission-mode 沿用当前设置。[/red]") raise typer.Exit(code=1) if e.response.status_code in (401, 403): console.print("[red]Error: Authentication failed[/red]") raise typer.Exit(code=4) console.print(f"[red]Error: HTTP {e.response.status_code}[/red]") raise typer.Exit(code=1) # 7. 更新本地会话缓存(优先使用后端响应中的 sessionId) final_sid = response_sid or resolved_sid or "" if not no_session: cache.save_last_session(wd, final_sid, model or "") def _stream_query(client: AicaClient, body: dict, verbose: bool) -> Optional[str]: """SSE 流式查询 — POST /api/query/stream""" response_session_id = None for event in client.stream_query(body): print(json.dumps(event, ensure_ascii=False), flush=True) # 从 message_complete 事件中提取 sessionId if isinstance(event, dict) and event.get("sessionId"): response_session_id = event["sessionId"] return response_session_id def _sync_query_json(client: AicaClient, body: dict) -> Optional[str]: """同步查询 JSON 输出""" data = client.sync_query(body) print(json.dumps(data, ensure_ascii=False, indent=2)) return data.get("sessionId") def _sync_query_text(client: AicaClient, body: dict, verbose: bool, quiet: bool) -> Optional[str]: """同步查询文本输出""" if not quiet: console.print("[dim]Thinking...[/dim]") data = client.sync_query(body) result = data.get("result", "") if sys.stdout.isatty(): stdout_console.print(Markdown(result)) else: print(result) if verbose and not quiet: usage = data.get("usage", {}) cost = data.get("costUsd", 0) console.print( f"[dim]Tokens: {usage.get('inputTokens', 0)}in " f"+ {usage.get('outputTokens', 0)}out | " f"Cost: ${cost:.4f}[/dim]" ) return data.get("sessionId") if __name__ == "__main__": app()