diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 1c7dd9f7497e8..2b34b96635fe7 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -155,6 +155,9 @@ def _extract_url_query_params(url: str): "github-models": "copilot", "github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp", + "grok-build": "grok-build", + "grokbuild": "grok-build", + "grok-cli": "grok-build", "tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub", "tencent-cloud": "tencent-tokenhub", @@ -3225,31 +3228,43 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", or _read_main_model(), provider, ) - if provider == "copilot-acp": + if provider in {"copilot-acp", "grok-build"}: api_key = str(creds.get("api_key", "")).strip() base_url = str(creds.get("base_url", "")).strip() command = str(creds.get("command", "")).strip() or None args = list(creds.get("args") or []) if not final_model: logger.warning( - "resolve_provider_client: copilot-acp requested but no model " - "was provided or configured" + "resolve_provider_client: %s requested but no model " + "was provided or configured", + provider, ) return None, None if not api_key or not base_url: logger.warning( - "resolve_provider_client: copilot-acp requested but external " - "process credentials are incomplete" + "resolve_provider_client: %s requested but external " + "process credentials are incomplete", + provider, ) return None, None - from agent.copilot_acp_client import CopilotACPClient + if provider == "grok-build": + from agent.grok_cli_client import GrokCliClient + + client = GrokCliClient( + api_key=api_key, + base_url=base_url, + command=command, + args=args, + ) + else: + from agent.copilot_acp_client import CopilotACPClient - client = CopilotACPClient( - api_key=api_key, - base_url=base_url, - command=command, - args=args, - ) + client = CopilotACPClient( + api_key=api_key, + base_url=base_url, + command=command, + args=args, + ) logger.debug("resolve_provider_client: %s (%s)", provider, final_model) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) diff --git a/agent/grok_cli_client.py b/agent/grok_cli_client.py new file mode 100644 index 0000000000000..34ebc1c2777f0 --- /dev/null +++ b/agent/grok_cli_client.py @@ -0,0 +1,332 @@ +"""OpenAI-compatible shim that forwards Hermes requests to the Grok CLI.""" + +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import tempfile +import threading +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +from agent.copilot_acp_client import _extract_tool_calls_from_text + +GROK_CLI_MARKER_BASE_URL = "grok-cli://local" +_DEFAULT_TIMEOUT_SECONDS = 900.0 + + +def _resolve_command() -> str: + for candidate in ( + os.getenv("HERMES_GROK_BUILD_COMMAND", "").strip(), + os.getenv("GROK_CLI_PATH", "").strip(), + os.path.expanduser("~/.grok/bin/grok"), + os.path.expanduser("~/.local/bin/grok"), + "grok", + ): + if not candidate: + continue + if os.path.isabs(candidate): + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + continue + return candidate + return "grok" + + +def _resolve_args() -> list[str]: + raw = os.getenv("HERMES_GROK_BUILD_ARGS", "").strip() + if raw: + return shlex.split(raw) + effort = ( + os.getenv("HERMES_GROK_BUILD_EFFORT", "").strip() + or os.getenv("GROK_BUILD_EFFORT", "").strip() + or "xhigh" + ) + return [ + "--no-memory", + "--disable-web-search", + "--max-turns", + "1", + "--output-format", + "plain", + "--effort", + effort, + ] + + +def _normalize_timeout(timeout: Any) -> float: + if timeout is None: + return _DEFAULT_TIMEOUT_SECONDS + if isinstance(timeout, (int, float)): + return float(timeout) + candidates = [ + getattr(timeout, attr, None) + for attr in ("read", "write", "connect", "pool", "timeout") + ] + numeric = [float(v) for v in candidates if isinstance(v, (int, float))] + return max(numeric) if numeric else _DEFAULT_TIMEOUT_SECONDS + + +def _build_subprocess_env() -> dict[str, str]: + env = os.environ.copy() + home = os.environ.get("HOME", "").strip() or os.path.expanduser("~") + if home and home != "~": + env["HOME"] = home + # Non-interactive launchd/SSH services often miss user-local CLI install dirs. + path = env.get("PATH", "") + extras = [os.path.expanduser("~/.grok/bin"), os.path.expanduser("~/.local/bin")] + env["PATH"] = os.pathsep.join([p for p in extras + [path] if p]) + return env + + +def _format_messages_as_prompt( + messages: list[dict[str, Any]], + *, + model: str | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any = None, +) -> str: + sections: list[str] = [ + "You are the active Grok Build backend for Hermes Agent.", + "Complete the latest user request using the conversation transcript below.", + "If a tool is needed, emit ONLY {...} blocks with JSON in OpenAI function-call shape.", + "If no tool is needed, answer normally.", + ] + if model: + sections.append(f"Hermes requested model: {model}") + + tool_specs: list[dict[str, Any]] = [] + for tool in tools or []: + if not isinstance(tool, dict): + continue + fn = tool.get("function") or {} + if not isinstance(fn, dict): + continue + name = fn.get("name") + if not isinstance(name, str) or not name.strip(): + continue + tool_specs.append( + { + "name": name.strip(), + "description": fn.get("description", ""), + "parameters": fn.get("parameters", {}), + } + ) + if tool_specs: + sections.append( + "Available tools. To use one, emit a single {...} " + "object with id/type/function{name,arguments}; arguments must be a JSON string.\n" + + json.dumps(tool_specs, ensure_ascii=False) + ) + if tool_choice is not None: + sections.append(f"Tool choice hint: {json.dumps(tool_choice, ensure_ascii=False)}") + + transcript: list[str] = [] + for message in messages or []: + if not isinstance(message, dict): + continue + role = str(message.get("role") or "context").strip().lower() + if role not in {"system", "user", "assistant", "tool"}: + role = "context" + rendered = _render_message_content(message.get("content")) + if rendered: + transcript.append(f"{role.title()}:\n{rendered}") + if transcript: + sections.append("Conversation transcript:\n\n" + "\n\n".join(transcript)) + + sections.append("Continue from the latest user message.") + return "\n\n".join(s.strip() for s in sections if s and s.strip()) + + +def _render_message_content(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content.strip() + if isinstance(content, dict): + text = content.get("text") + if isinstance(text, str): + return text.strip() + inner = content.get("content") + if isinstance(inner, str): + return inner.strip() + return json.dumps(content, ensure_ascii=True) + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + text = item.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + return "\n".join(parts).strip() + return str(content).strip() + + +def _has_flag(args: list[str], *flags: str) -> bool: + return any(arg in flags or any(arg.startswith(flag + "=") for flag in flags) for arg in args) + + +class _GrokChatCompletions: + def __init__(self, client: "GrokCliClient"): + self._client = client + + def create(self, **kwargs: Any) -> Any: + return self._client._create_chat_completion(**kwargs) + + +class _GrokChatNamespace: + def __init__(self, client: "GrokCliClient"): + self.completions = _GrokChatCompletions(client) + + +class GrokCliClient: + """Minimal OpenAI-client-compatible facade for `grok --single`.""" + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + default_headers: dict[str, str] | None = None, + command: str | None = None, + args: list[str] | None = None, + grok_command: str | None = None, + grok_args: list[str] | None = None, + cwd: str | None = None, + **_: Any, + ): + self.api_key = api_key or "grok-build" + self.base_url = base_url or GROK_CLI_MARKER_BASE_URL + self._default_headers = dict(default_headers or {}) + self._command = grok_command or command or _resolve_command() + self._args = list(grok_args or args or _resolve_args()) + self._cwd = str(Path(cwd or os.getcwd()).resolve()) + self.chat = _GrokChatNamespace(self) + self.is_closed = False + self._active_process: subprocess.Popen[str] | None = None + self._active_process_lock = threading.Lock() + + def close(self) -> None: + proc: subprocess.Popen[str] | None + with self._active_process_lock: + proc = self._active_process + self._active_process = None + self.is_closed = True + if proc is None: + return + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + + def _create_chat_completion( + self, + *, + model: str | None = None, + messages: list[dict[str, Any]] | None = None, + timeout: Any = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any = None, + **_: Any, + ) -> Any: + prompt_text = _format_messages_as_prompt( + messages or [], + model=model, + tools=tools, + tool_choice=tool_choice, + ) + response_text = self._run_prompt( + prompt_text, + model=model or "grok-build", + timeout_seconds=_normalize_timeout(timeout), + ) + tool_calls, cleaned_text = _extract_tool_calls_from_text(response_text) + + usage = SimpleNamespace( + prompt_tokens=0, + completion_tokens=0, + total_tokens=0, + prompt_tokens_details=SimpleNamespace(cached_tokens=0), + ) + assistant_message = SimpleNamespace( + content=cleaned_text, + tool_calls=tool_calls, + reasoning=None, + reasoning_content=None, + reasoning_details=None, + ) + finish_reason = "tool_calls" if tool_calls else "stop" + choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason) + return SimpleNamespace(choices=[choice], usage=usage, model=model or "grok-build") + + def _run_prompt(self, prompt_text: str, *, model: str, timeout_seconds: float) -> str: + cmd = [self._command] + list(self._args) + if not _has_flag(cmd, "--model", "-m"): + cmd.extend(["--model", model]) + prompt_file_path: str | None = None + if not _has_flag(cmd, "--single", "-p", "--prompt", "--prompt-file", "--prompt-json"): + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + suffix=".txt", + prefix="hermes-grok-", + delete=False, + ) as prompt_file: + prompt_file.write(prompt_text) + prompt_file_path = prompt_file.name + cmd.extend(["--prompt-file", prompt_file_path]) + + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=self._cwd, + env=_build_subprocess_env(), + ) + except FileNotFoundError as exc: + if prompt_file_path: + try: + os.unlink(prompt_file_path) + except OSError: + pass + raise RuntimeError( + f"Could not start Grok CLI command '{self._command}'. " + "Install xAI's Grok CLI or set HERMES_GROK_BUILD_COMMAND/GROK_CLI_PATH." + ) from exc + + self.is_closed = False + with self._active_process_lock: + self._active_process = proc + + try: + stdout, stderr = proc.communicate(timeout=timeout_seconds) + except subprocess.TimeoutExpired as exc: + self.close() + raise TimeoutError(f"Timed out waiting for Grok CLI after {timeout_seconds:.0f}s.") from exc + finally: + with self._active_process_lock: + if self._active_process is proc: + self._active_process = None + if prompt_file_path: + try: + os.unlink(prompt_file_path) + except OSError: + pass + + if proc.returncode != 0: + stderr_tail = "\n".join((stderr or "").splitlines()[-20:]).strip() + detail = stderr_tail or (stdout or "").strip() or f"exit code {proc.returncode}" + raise RuntimeError(f"Grok CLI returned exit code {proc.returncode}: {detail}") + + return (stdout or "").strip() diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 41e229416c9d9..012152f1cc4f3 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -45,7 +45,7 @@ def _resolve_requests_verify() -> bool | str: # Only these are stripped — Ollama-style "model:tag" colons (e.g. "qwen3.5:27b") # are preserved so the full model name reaches cache lookups and server queries. _PROVIDER_PREFIXES: frozenset[str] = frozenset({ - "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", + "openrouter", "nous", "openai-codex", "grok-build", "copilot", "copilot-acp", "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", "opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba", "novita", "qwen-oauth", @@ -208,6 +208,7 @@ def _strip_provider_prefix(model: str) -> str: # via a custom provider. Values sourced from models.dev (2026-04). # Keys use substring matching (longest-first), so e.g. "grok-4.20" # matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309". + "grok-build": 512000, # Grok Build CLI; static default until CLI exposes metadata "grok-code-fast": 256000, # grok-code-fast-1 "grok-4-1-fast": 2000000, # grok-4-1-fast-(non-)reasoning "grok-2-vision": 8192, # grok-2-vision, -1212, -latest @@ -1477,6 +1478,14 @@ def get_model_context_length( # local servers actually know about. Ollama "model:tag" colons are preserved. model = _strip_provider_prefix(model) + # Grok Build is a local CLI transport, not an OpenAI-compatible HTTP + # endpoint. The ``grok-cli://local`` marker should not go through the + # custom-endpoint probe path and accidentally inherit the generic fallback. + provider_key = (provider or "").strip().lower() + base_url_key = (base_url or "").strip().lower() + if provider_key == "grok-build" or base_url_key.startswith("grok-cli://"): + return DEFAULT_CONTEXT_LENGTHS["grok-build"] + # 1. Check persistent cache (model+provider) # LM Studio is excluded — its loaded context length is transient (the # user can reload the model with a different context_length at any time diff --git a/agent/transports/hermes_tools_mcp_server.py b/agent/transports/hermes_tools_mcp_server.py index 37f2d6179d117..e7d983203c18f 100644 --- a/agent/transports/hermes_tools_mcp_server.py +++ b/agent/transports/hermes_tools_mcp_server.py @@ -19,6 +19,7 @@ - vision_analyze — image inspection by vision model - image_generate — image generation - skill_view, skills_list — Hermes' skill library + - obsidian_read_tasks — structured Obsidian task reads - text_to_speech — TTS - kanban_* (complete/block/comment/ — kanban worker + orchestrator heartbeat/show/list/create/ handoff (stateless: read env var, @@ -82,6 +83,7 @@ "image_generate", "skill_view", "skills_list", + "obsidian_read_tasks", "text_to_speech", # Kanban worker handoff tools — gated on HERMES_KANBAN_TASK env var # (set by the kanban dispatcher when spawning a worker). Without these diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index 0fffb82d0b949..5b3b2ed9cabf4 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -65,6 +65,15 @@ # Supported image extensions for inline detection _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} + +def _env_bool(name: str, default: bool) -> bool: + """Parse common boolean environment values.""" + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + def _send_imap_id(imap: "imaplib.IMAP4") -> None: """Send RFC 2971 IMAP ID command identifying this client. @@ -255,6 +264,9 @@ def __init__(self, config: PlatformConfig): self._smtp_host = os.getenv("EMAIL_SMTP_HOST", "") self._smtp_port = int(os.getenv("EMAIL_SMTP_PORT", "587")) self._poll_interval = int(os.getenv("EMAIL_POLL_INTERVAL", "15")) + self._use_ssl = _env_bool("EMAIL_USE_SSL", True) + self._imap_starttls = _env_bool("EMAIL_IMAP_STARTTLS", False) + self._smtp_starttls = _env_bool("EMAIL_SMTP_STARTTLS", self._use_ssl) # Skip attachments — configured via config.yaml: # platforms: @@ -273,6 +285,23 @@ def __init__(self, config: PlatformConfig): logger.info("[Email] Adapter initialized for %s", self._address) + def _open_imap(self) -> "imaplib.IMAP4": + """Open IMAP using configured transport security.""" + if self._use_ssl: + return imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30) + + imap = imaplib.IMAP4(self._imap_host, self._imap_port, timeout=30) + if self._imap_starttls: + imap.starttls(ssl_context=ssl.create_default_context()) + return imap + + def _open_smtp(self) -> smtplib.SMTP: + """Open SMTP using configured transport security.""" + smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) + if self._smtp_starttls: + smtp.starttls(context=ssl.create_default_context()) + return smtp + def _trim_seen_uids(self) -> None: """Keep only the most recent UIDs to prevent unbounded memory growth. @@ -297,7 +326,7 @@ async def connect(self) -> bool: """Connect to the IMAP server and start polling for new messages.""" try: # Test IMAP connection - imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30) + imap = self._open_imap() imap.login(self._address, self._password) _send_imap_id(imap) # Mark all existing messages as seen so we only process new ones @@ -316,8 +345,7 @@ async def connect(self) -> bool: try: # Test SMTP connection - smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) - smtp.starttls(context=ssl.create_default_context()) + smtp = self._open_smtp() smtp.login(self._address, self._password) smtp.quit() logger.info("[Email] SMTP connection test passed.") @@ -365,7 +393,7 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]: """Fetch new (unseen) messages from IMAP. Runs in executor thread.""" results = [] try: - imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30) + imap = self._open_imap() try: imap.login(self._address, self._password) _send_imap_id(imap) @@ -548,9 +576,8 @@ def _send_email( msg.attach(MIMEText(body, "plain", "utf-8")) - smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) + smtp = self._open_smtp() try: - smtp.starttls(context=ssl.create_default_context()) smtp.login(self._address, self._password) smtp.send_message(msg) finally: @@ -670,9 +697,8 @@ def _send_email_with_attachments( except Exception as e: logger.warning("[Email] Failed to attach %s: %s", file_path, e) - smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) + smtp = self._open_smtp() try: - smtp.starttls(context=ssl.create_default_context()) smtp.login(self._address, self._password) smtp.send_message(msg) finally: @@ -749,9 +775,8 @@ def _send_email_with_attachment( part.add_header("Content-Disposition", f"attachment; filename={fname}") msg.attach(part) - smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) + smtp = self._open_smtp() try: - smtp.starttls(context=ssl.create_default_context()) smtp.login(self._address, self._password) smtp.send_message(msg) finally: diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 6cabb61570d79..18946c6c71a6e 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -84,6 +84,7 @@ DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1" DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com" DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot" +DEFAULT_GROK_BUILD_BASE_URL = "grok-cli://local" DEFAULT_OLLAMA_CLOUD_BASE_URL = "https://ollama.com/v1" STEPFUN_STEP_PLAN_INTL_BASE_URL = "https://api.stepfun.ai/step_plan/v1" STEPFUN_STEP_PLAN_CN_BASE_URL = "https://api.stepfun.com/step_plan/v1" @@ -215,6 +216,13 @@ class ProviderConfig: inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL, base_url_env_var="COPILOT_ACP_BASE_URL", ), + "grok-build": ProviderConfig( + id="grok-build", + name="Grok Build CLI", + auth_type="external_process", + inference_base_url=DEFAULT_GROK_BUILD_BASE_URL, + base_url_env_var="HERMES_GROK_BUILD_BASE_URL", + ), "gemini": ProviderConfig( id="gemini", name="Google AI Studio", @@ -1384,6 +1392,8 @@ def resolve_provider( "x-ai": "xai", "x.ai": "xai", "grok": "xai", "xai-oauth": "xai-oauth", "x-ai-oauth": "xai-oauth", "grok-oauth": "xai-oauth", "xai-grok-oauth": "xai-oauth", + "grok-build": "grok-build", "grokbuild": "grok-build", + "grok-cli": "grok-build", "xai-build": "grok-build", "kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding", "kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn", "step": "stepfun", "stepfun-coding-plan": "stepfun", @@ -4674,27 +4684,22 @@ def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]: if not pconfig or pconfig.auth_type != "external_process": return {"configured": False} - command = ( - os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip() - or os.getenv("COPILOT_CLI_PATH", "").strip() - or "copilot" - ) - raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip() - args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"] + command, args = _external_process_command_and_args(provider_id) base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else "" if not base_url: base_url = pconfig.inference_base_url resolved_command = shutil.which(command) if command else None + configured = bool(resolved_command or _external_process_allows_without_command(provider_id, base_url)) return { - "configured": bool(resolved_command or base_url.startswith("acp+tcp://")), + "configured": configured, "provider": provider_id, "name": pconfig.name, "command": command, "args": args, "resolved_command": resolved_command, "base_url": base_url, - "logged_in": bool(resolved_command or base_url.startswith("acp+tcp://")), + "logged_in": configured, } @@ -4715,10 +4720,10 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: return get_gemini_oauth_auth_status() if target == "minimax-oauth": return get_minimax_oauth_auth_status() - if target == "copilot-acp": + pconfig = PROVIDER_REGISTRY.get(target) + if pconfig and pconfig.auth_type == "external_process": return get_external_process_provider_status(target) # API-key providers - pconfig = PROVIDER_REGISTRY.get(target) if pconfig and pconfig.auth_type == "api_key": return get_api_key_provider_status(target) # AWS SDK providers (Bedrock) — check via boto3 credential chain @@ -4776,6 +4781,67 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: } +def _grok_cli_default_command() -> str: + """Return a useful default command for the Grok CLI. + + The official installer places binaries under ~/.grok/bin and often adds a + ~/.local/bin shim, but non-interactive services do not always inherit that + PATH. Prefer explicit installed paths before falling back to PATH lookup. + """ + for candidate in ( + os.path.expanduser("~/.grok/bin/grok"), + os.path.expanduser("~/.local/bin/grok"), + ): + if candidate and os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return "grok" + + +def _external_process_command_and_args(provider_id: str) -> tuple[str, list[str]]: + """Resolve provider-specific subprocess command defaults.""" + if provider_id == "grok-build": + command = ( + os.getenv("HERMES_GROK_BUILD_COMMAND", "").strip() + or os.getenv("GROK_CLI_PATH", "").strip() + or _grok_cli_default_command() + ) + raw_args = os.getenv("HERMES_GROK_BUILD_ARGS", "").strip() + if raw_args: + args = shlex.split(raw_args) + else: + effort = ( + os.getenv("HERMES_GROK_BUILD_EFFORT", "").strip() + or os.getenv("GROK_BUILD_EFFORT", "").strip() + or "xhigh" + ) + args = [ + "--no-memory", + "--disable-web-search", + "--max-turns", + "1", + "--output-format", + "plain", + "--effort", + effort, + ] + return command, args + + command = ( + os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip() + or os.getenv("COPILOT_CLI_PATH", "").strip() + or "copilot" + ) + raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip() + args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"] + return command, args + + +def _external_process_allows_without_command(provider_id: str, base_url: str) -> bool: + if provider_id == "copilot-acp": + return base_url.startswith("acp+tcp://") + return False + + def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, Any]: """Resolve runtime details for local subprocess-backed providers.""" pconfig = PROVIDER_REGISTRY.get(provider_id) @@ -4790,25 +4856,25 @@ def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, if not base_url: base_url = pconfig.inference_base_url - command = ( - os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip() - or os.getenv("COPILOT_CLI_PATH", "").strip() - or "copilot" - ) - raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip() - args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"] + command, args = _external_process_command_and_args(provider_id) resolved_command = shutil.which(command) if command else None - if not resolved_command and not base_url.startswith("acp+tcp://"): + if not resolved_command and not _external_process_allows_without_command(provider_id, base_url): + command_label = "Grok CLI" if provider_id == "grok-build" else "Copilot CLI" + env_hint = ( + "HERMES_GROK_BUILD_COMMAND/GROK_CLI_PATH" + if provider_id == "grok-build" + else "HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH" + ) raise AuthError( - f"Could not find the Copilot CLI command '{command}'. " - "Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH.", + f"Could not find the {command_label} command '{command}'. " + f"Install the CLI or set {env_hint}.", provider=provider_id, - code="missing_copilot_cli", + code=f"missing_{provider_id.replace('-', '_')}_cli", ) return { "provider": provider_id, - "api_key": "copilot-acp", + "api_key": provider_id, "base_url": base_url.rstrip("/"), "command": resolved_command or command, "args": args, diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 41c4a23f93288..2c02762a79f97 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1988,6 +1988,8 @@ def _lookup_ref(name: str, provider_key: str, model: str) -> str: _model_flow_openai_codex(config, current_model) elif selected_provider == "xai-oauth": _model_flow_xai_oauth(config, current_model) + elif selected_provider == "grok-build": + _model_flow_grok_build(config, current_model) elif selected_provider == "qwen-oauth": _model_flow_qwen_oauth(config, current_model) elif selected_provider == "minimax-oauth": @@ -4372,6 +4374,62 @@ def _model_flow_copilot_acp(config, current_model=""): print(f"Default model set to: {selected} (via {pconfig.name})") +def _model_flow_grok_build(config, current_model=""): + """Grok Build flow using the local Grok CLI.""" + from hermes_cli.auth import ( + PROVIDER_REGISTRY, + _prompt_model_selection, + _save_model_choice, + deactivate_provider, + get_external_process_provider_status, + resolve_external_process_provider_credentials, + ) + from hermes_cli.config import load_config, save_config + from hermes_cli.models import _PROVIDER_MODELS + + del config + + provider_id = "grok-build" + pconfig = PROVIDER_REGISTRY[provider_id] + status = get_external_process_provider_status(provider_id) + resolved_command = status.get("resolved_command") or status.get("command") or "grok" + effective_base = status.get("base_url") or pconfig.inference_base_url + + print(" Grok Build delegates Hermes turns to the local `grok` CLI.") + print(" It uses your existing Grok/Grok Build subscription login.") + print(f" Command: {resolved_command}") + print(f" Backend marker: {effective_base}") + print() + + try: + creds = resolve_external_process_provider_credentials(provider_id) + except Exception as exc: + print(f" ⚠ {exc}") + print(" Install xAI's Grok CLI or set HERMES_GROK_BUILD_COMMAND/GROK_CLI_PATH.") + return + + effective_base = creds.get("base_url") or effective_base + model_list = _PROVIDER_MODELS.get(provider_id, ["grok-build"]) + selected = _prompt_model_selection(model_list, current_model=current_model) + if not selected: + print("No change.") + return + + _save_model_choice(selected) + cfg = load_config() + model = cfg.get("model") + if not isinstance(model, dict): + model = {"default": model} if model else {} + cfg["model"] = model + model["provider"] = provider_id + model["base_url"] = effective_base + model["api_mode"] = "chat_completions" + save_config(cfg) + deactivate_provider() + + print(f"Default model set to: {selected} (via {pconfig.name})") + + def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "") -> tuple: """Shared API-key entry point for ``hermes setup`` / ``hermes model``. @@ -9580,7 +9638,7 @@ def _build_provider_choices() -> list[str]: except Exception: # Fallback: static list guarantees the CLI always works return [ - "auto", "openrouter", "nous", "openai-codex", "xai-oauth", "copilot-acp", "copilot", + "auto", "openrouter", "nous", "openai-codex", "xai-oauth", "grok-build", "copilot-acp", "copilot", "anthropic", "gemini", "google-gemini-cli", "xai", "bedrock", "azure-foundry", "ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-cn", "kilocode", "novita", "xiaomi", "arcee", diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 0e74db718d936..df36e42d16fc9 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -80,6 +80,7 @@ _STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({ "copilot", "copilot-acp", + "grok-build", "openai-codex", }) @@ -470,4 +471,3 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: # --------------------------------------------------------------------------- # Batch / convenience helpers # --------------------------------------------------------------------------- - diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index fec1f33d09254..6851ec064f3d8 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1346,14 +1346,11 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: if not has_creds: continue - if hermes_slug in {"openai-codex", "copilot", "copilot-acp"}: - # Use live OAuth-backed discovery so the gateway /model picker - # matches what the user's authenticated Codex/Copilot backend - # actually serves — including ChatGPT-Pro-only Codex slugs - # (e.g. gpt-5.3-codex-spark) that aren't in the static curated - # catalog. ``provider_model_ids()`` falls back to the curated - # list when the live endpoint is unreachable, so this is safe - # for unauthenticated and offline cases too. + if hermes_slug in {"openai-codex", "grok-build", "copilot", "copilot-acp"}: + # Use provider-aware discovery/fallbacks so the gateway /model + # picker matches authenticated backends where possible, while + # subprocess-backed providers (ACP/Grok Build) still expose their + # curated local model IDs. model_ids = provider_model_ids(hermes_slug) # For aws_sdk providers (bedrock), use live discovery so the list # reflects the active region (eu.*, ap.*) not the static us.* list. diff --git a/hermes_cli/models.py b/hermes_cli/models.py index ded3f448f87cc..2ced96f5114bd 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -201,6 +201,9 @@ def _xai_curated_models() -> list[str]: ], "openai-codex": _codex_curated_models(), "xai-oauth": _xai_curated_models(), + "grok-build": [ + "grok-build", + ], "copilot-acp": [ "copilot-acp", ], @@ -930,6 +933,7 @@ class ProviderEntry(NamedTuple): ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"), ProviderEntry("alibaba", "Qwen Cloud", "Qwen Cloud / DashScope Coding (Qwen + multi-provider)"), ProviderEntry("xai-oauth", "xAI Grok OAuth (SuperGrok Subscription)", "xAI Grok OAuth (SuperGrok Subscription)"), + ProviderEntry("grok-build", "Grok Build CLI", "Grok Build CLI (uses local `grok` subscription login)"), ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2.5 and V2 models — pro, omni, flash)"), ProviderEntry("tencent-tokenhub", "Tencent TokenHub", "Tencent TokenHub (Hy3 Preview — direct API via tokenhub.tencentmaas.com)"), ProviderEntry("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models — build.nvidia.com or local NIM)"), @@ -1049,6 +1053,10 @@ class ProviderEntry(NamedTuple): "amazon": "bedrock", "grok": "xai", "grok-oauth": "xai-oauth", + "grok-build": "grok-build", + "grokbuild": "grok-build", + "grok-cli": "grok-build", + "xai-build": "grok-build", "xai-oauth": "xai-oauth", "x-ai-oauth": "xai-oauth", "xai-grok-oauth": "xai-oauth", @@ -2184,6 +2192,8 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) return get_codex_model_ids(access_token=access_token) if normalized == "xai-oauth": return list(_PROVIDER_MODELS.get("xai-oauth", _PROVIDER_MODELS.get("xai", []))) + if normalized == "grok-build": + return list(_PROVIDER_MODELS.get("grok-build", [])) if normalized in {"copilot", "copilot-acp"}: try: live = _fetch_github_models(_resolve_copilot_catalog_api_key()) @@ -3463,7 +3473,7 @@ def validate_requested_model( } # Providers with non-standard catalog validation — /v1/models probing is not the right path. - if normalized in {"openai-codex", "xai-oauth"}: + if normalized in {"openai-codex", "xai-oauth", "grok-build"}: try: catalog_models = provider_model_ids(normalized) except Exception: @@ -3490,7 +3500,7 @@ def validate_requested_model( suggestion_text = "" if suggestions: suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions) - provider_label = "OpenAI Codex" if normalized == "openai-codex" else "xAI Grok OAuth (SuperGrok Subscription)" + provider_label = _PROVIDER_LABELS.get(normalized, normalized) return { "accepted": True, "persist": True, diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 9243b3f6f8492..74f30013d2120 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -90,6 +90,12 @@ class HermesOverlay: base_url_override="acp://copilot", base_url_env_var="COPILOT_ACP_BASE_URL", ), + "grok-build": HermesOverlay( + transport="openai_chat", + auth_type="external_process", + base_url_override="grok-cli://local", + base_url_env_var="HERMES_GROK_BUILD_BASE_URL", + ), "github-copilot": HermesOverlay( transport="openai_chat", extra_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN"), @@ -251,6 +257,10 @@ class ProviderDef: "x.ai": "xai", "grok": "xai", "grok-oauth": "xai-oauth", + "grok-build": "grok-build", + "grokbuild": "grok-build", + "grok-cli": "grok-build", + "xai-build": "grok-build", "xai-oauth": "xai-oauth", "x-ai-oauth": "xai-oauth", "xai-grok-oauth": "xai-oauth", @@ -371,6 +381,7 @@ class ProviderDef: _LABEL_OVERRIDES: Dict[str, str] = { "nous": "Nous Portal", "openai-codex": "OpenAI Codex", + "grok-build": "Grok Build CLI", "copilot-acp": "GitHub Copilot ACP", "stepfun": "StepFun Step Plan", "xiaomi": "Xiaomi MiMo", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c0baf14db924b..e42cb885fb74f 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1220,6 +1220,19 @@ def resolve_runtime_provider( "requested_provider": requested_provider, } + if provider == "grok-build": + creds = resolve_external_process_provider_credentials(provider) + return { + "provider": "grok-build", + "api_mode": "chat_completions", + "base_url": creds.get("base_url", "").rstrip("/"), + "api_key": creds.get("api_key", ""), + "command": creds.get("command", ""), + "args": list(creds.get("args") or []), + "source": creds.get("source", "process"), + "requested_provider": requested_provider, + } + # Anthropic (native Messages API) if provider == "anthropic": # Allow base URL override from config.yaml model.base_url, but only diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 50e198b9dc7fb..4737ca86004dc 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -72,6 +72,9 @@ def _supports_same_provider_pool_setup(provider: str) -> bool: # Default model lists per provider — used as fallback when the live # /models endpoint can't be reached. _DEFAULT_PROVIDER_MODELS = { + "grok-build": [ + "grok-build", + ], "copilot-acp": [ "copilot-acp", ], @@ -931,6 +934,7 @@ def setup_model_provider(config: dict, *, quick: bool = False): "nous-api": "Nous Portal API key", "copilot": "GitHub Copilot", "copilot-acp": "GitHub Copilot ACP", + "grok-build": "Grok Build CLI", "zai": "Z.AI / GLM", "kimi-coding": "Kimi / Moonshot", "kimi-coding-cn": "Kimi / Moonshot (China)", diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 377194589ea10..a7ce6445ead06 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -56,6 +56,7 @@ ("browser", "🌐 Browser Automation", "navigate, click, type, scroll"), ("terminal", "💻 Terminal & Processes", "terminal, process"), ("file", "📁 File Operations", "read, write, patch, search"), + ("obsidian", "📝 Obsidian", "structured task reader"), ("code_execution", "⚡ Code Execution", "execute_code"), ("vision", "👁️ Vision / Image Analysis", "vision_analyze"), ("video", "🎬 Video Analysis", "video_analyze (requires video-capable model)"), diff --git a/run_agent.py b/run_agent.py index 85c1128d68e5f..8127a9edd14ea 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1337,9 +1337,10 @@ def __init__( if ( api_mode is None and self.api_mode == "chat_completions" - and self.provider != "copilot-acp" + and self.provider not in {"copilot-acp", "grok-build"} and not str(self.base_url or "").lower().startswith("acp://copilot") and not str(self.base_url or "").lower().startswith("acp+tcp://") + and not str(self.base_url or "").lower().startswith("grok-cli://") and not self._is_azure_openai_url() and ( self._is_direct_openai_url() @@ -1657,7 +1658,7 @@ def __init__( client_kwargs = {"api_key": api_key, "base_url": base_url} if _provider_timeout is not None: client_kwargs["timeout"] = _provider_timeout - if self.provider == "copilot-acp": + if self.provider in {"copilot-acp", "grok-build"}: client_kwargs["command"] = self.acp_command client_kwargs["args"] = self.acp_args effective_base = base_url @@ -6726,6 +6727,17 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo self._client_log_context(), ) return client + if self.provider == "grok-build" or str(client_kwargs.get("base_url", "")).startswith("grok-cli://"): + from agent.grok_cli_client import GrokCliClient + + client = GrokCliClient(**client_kwargs) + logger.info( + "Grok CLI client created (%s, shared=%s) %s", + reason, + shared, + self._client_log_context(), + ) + return client if self.provider == "google-gemini-cli" or str(client_kwargs.get("base_url", "")).startswith("cloudcode-pa://"): from agent.gemini_cloudcode_adapter import GeminiCloudCodeClient @@ -12943,6 +12955,8 @@ def _stop_spinner(): self.provider == "copilot-acp" or str(self.base_url or "").lower().startswith("acp://copilot") or str(self.base_url or "").lower().startswith("acp+tcp://") + or self.provider == "grok-build" + or str(self.base_url or "").lower().startswith("grok-cli://") ): _use_streaming = False elif not self._has_stream_consumers(): diff --git a/tests/agent/test_grok_cli_client.py b/tests/agent/test_grok_cli_client.py new file mode 100644 index 0000000000000..e5211c831c581 --- /dev/null +++ b/tests/agent/test_grok_cli_client.py @@ -0,0 +1,66 @@ +from agent.grok_cli_client import GrokCliClient + + +def test_grok_cli_client_runs_single_prompt(monkeypatch): + calls = [] + + class FakeProcess: + returncode = 0 + + def communicate(self, timeout=None): + return "GROK_OK\n", "" + + def fake_popen(cmd, **kwargs): + calls.append((cmd, kwargs)) + return FakeProcess() + + monkeypatch.setattr("agent.grok_cli_client.subprocess.Popen", fake_popen) + + client = GrokCliClient( + command="/usr/local/bin/grok", + args=["--no-memory", "--output-format", "plain"], + ) + response = client.chat.completions.create( + model="grok-build", + messages=[{"role": "user", "content": "Reply with GROK_OK"}], + timeout=5, + ) + + assert response.choices[0].message.content == "GROK_OK" + cmd, kwargs = calls[0] + assert cmd[0] == "/usr/local/bin/grok" + assert "--prompt-file" in cmd + assert "--model" in cmd + assert "grok-build" in cmd + assert kwargs["stdout"] is not None + assert kwargs["stderr"] is not None + + +def test_grok_cli_client_preserves_tool_calls(monkeypatch): + raw = ( + '{"id":"call_1","type":"function",' + '"function":{"name":"terminal","arguments":"{\\"cmd\\":\\"pwd\\"}"}}' + "" + ) + + class FakeProcess: + returncode = 0 + + def communicate(self, timeout=None): + return raw, "" + + monkeypatch.setattr( + "agent.grok_cli_client.subprocess.Popen", + lambda *args, **kwargs: FakeProcess(), + ) + + client = GrokCliClient(command="/usr/local/bin/grok", args=["--no-memory"]) + response = client.chat.completions.create( + model="grok-build", + messages=[{"role": "user", "content": "Use terminal"}], + ) + + message = response.choices[0].message + assert response.choices[0].finish_reason == "tool_calls" + assert message.content == "" + assert message.tool_calls[0].function.name == "terminal" diff --git a/tests/agent/test_model_metadata_local_ctx.py b/tests/agent/test_model_metadata_local_ctx.py index f449255c0736b..ce5340f35cea0 100644 --- a/tests/agent/test_model_metadata_local_ctx.py +++ b/tests/agent/test_model_metadata_local_ctx.py @@ -519,6 +519,35 @@ def test_connection_error_returns_none(self): class TestGetModelContextLengthLocalFallback: """get_model_context_length uses local server query before falling back to 2M.""" + def test_grok_build_cli_uses_explicit_context_default(self): + """grok-cli://local is a CLI marker, not a custom HTTP endpoint.""" + from agent.model_metadata import get_model_context_length + + with patch("agent.model_metadata.get_cached_context_length") as mock_cache, \ + patch("agent.model_metadata._resolve_endpoint_context_length") as mock_endpoint: + result = get_model_context_length( + "grok-build", + base_url="grok-cli://local", + provider="grok-build", + ) + + assert result == 512000 + mock_cache.assert_not_called() + mock_endpoint.assert_not_called() + + def test_grok_build_context_config_override_wins(self): + """Explicit user config still beats the provider default.""" + from agent.model_metadata import get_model_context_length + + result = get_model_context_length( + "grok-build", + base_url="grok-cli://local", + provider="grok-build", + config_context_length=512000, + ) + + assert result == 512000 + def test_local_endpoint_unknown_model_queries_server(self): """Unknown model on local endpoint gets ctx from server, not 2M default.""" from agent.model_metadata import get_model_context_length diff --git a/tests/agent/transports/test_hermes_tools_mcp_server.py b/tests/agent/transports/test_hermes_tools_mcp_server.py index 3c11cb3f81dd7..b998d66496493 100644 --- a/tests/agent/transports/test_hermes_tools_mcp_server.py +++ b/tests/agent/transports/test_hermes_tools_mcp_server.py @@ -48,6 +48,7 @@ def test_expected_hermes_specific_tools_listed(self): "vision_analyze", "image_generate", "skill_view", + "obsidian_read_tasks", ): assert required in EXPOSED_TOOLS, f"missing {required!r}" diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 81859230ab792..f78691b454ab1 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -31,6 +31,7 @@ class TestProviderRegistry: @pytest.mark.parametrize("provider_id,name,auth_type", [ ("copilot-acp", "GitHub Copilot ACP", "external_process"), + ("grok-build", "Grok Build CLI", "external_process"), ("copilot", "GitHub Copilot", "api_key"), ("huggingface", "Hugging Face", "api_key"), ("zai", "Z.AI / GLM", "api_key"), @@ -120,6 +121,7 @@ def test_huggingface_env_vars(self): def test_base_urls(self): assert PROVIDER_REGISTRY["copilot"].inference_base_url == "https://api.githubcopilot.com" assert PROVIDER_REGISTRY["copilot-acp"].inference_base_url == "acp://copilot" + assert PROVIDER_REGISTRY["grok-build"].inference_base_url == "grok-cli://local" assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4" assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1" assert PROVIDER_REGISTRY["stepfun"].inference_base_url == STEPFUN_STEP_PLAN_INTL_BASE_URL @@ -393,6 +395,21 @@ def test_get_auth_status_dispatches_to_external_process(self, monkeypatch): assert status["configured"] is True assert status["provider"] == "copilot-acp" + def test_grok_build_status_detects_local_cli(self, monkeypatch): + monkeypatch.setenv("HERMES_GROK_BUILD_COMMAND", "grok") + monkeypatch.setenv("HERMES_GROK_BUILD_ARGS", "--no-memory --max-turns 1") + monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/opt/bin/{command}") + + status = get_external_process_provider_status("grok-build") + + assert status["configured"] is True + assert status["logged_in"] is True + assert status["provider"] == "grok-build" + assert status["command"] == "grok" + assert status["resolved_command"].endswith("/grok") + assert status["args"] == ["--no-memory", "--max-turns", "1"] + assert status["base_url"] == "grok-cli://local" + def test_non_api_key_provider(self): status = get_api_key_provider_status("nous") assert status["configured"] is False @@ -491,6 +508,20 @@ def test_resolve_copilot_acp_with_local_cli(self, monkeypatch): assert creds["args"] == ["--acp", "--stdio"] assert creds["source"] == "process" + def test_resolve_grok_build_with_local_cli(self, monkeypatch): + monkeypatch.setenv("HERMES_GROK_BUILD_COMMAND", "grok") + monkeypatch.setenv("HERMES_GROK_BUILD_ARGS", "--no-memory --disable-web-search") + monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}") + + creds = resolve_external_process_provider_credentials("grok-build") + + assert creds["provider"] == "grok-build" + assert creds["api_key"] == "grok-build" + assert creds["base_url"] == "grok-cli://local" + assert creds["command"] == "/usr/local/bin/grok" + assert creds["args"] == ["--no-memory", "--disable-web-search"] + assert creds["source"] == "process" + def test_resolve_kimi_with_key(self, monkeypatch): monkeypatch.setenv("KIMI_API_KEY", "kimi-secret-key") creds = resolve_api_key_provider_credentials("kimi-coding") @@ -712,6 +743,22 @@ def test_runtime_copilot_acp_uses_process_runtime(self, monkeypatch): assert result["command"] == "/usr/local/bin/copilot" assert result["args"] == ["--acp", "--stdio", "--debug"] + def test_runtime_grok_build_uses_process_runtime(self, monkeypatch): + monkeypatch.setenv("HERMES_GROK_BUILD_COMMAND", "grok") + monkeypatch.setenv("HERMES_GROK_BUILD_ARGS", "--no-memory --max-turns 1") + monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}") + + from hermes_cli.runtime_provider import resolve_runtime_provider + + result = resolve_runtime_provider(requested="grok-build") + + assert result["provider"] == "grok-build" + assert result["api_mode"] == "chat_completions" + assert result["api_key"] == "grok-build" + assert result["base_url"] == "grok-cli://local" + assert result["command"] == "/usr/local/bin/grok" + assert result["args"] == ["--no-memory", "--max-turns", "1"] + # ============================================================================= # _has_any_provider_configured tests diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 03c0fcca3d47a..f94d59cd40cb4 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -163,6 +163,7 @@ def test_known_aliases(self): assert normalize_provider("moonshot") == "kimi-coding" assert normalize_provider("step") == "stepfun" assert normalize_provider("github-copilot") == "copilot" + assert normalize_provider("grok-cli") == "grok-build" def test_case_insensitive(self): assert normalize_provider("OpenRouter") == "openrouter" @@ -175,6 +176,7 @@ def test_known_labels_and_auto(self): assert provider_label("stepfun") == "StepFun Step Plan" assert provider_label("copilot") == "GitHub Copilot" assert provider_label("copilot-acp") == "GitHub Copilot ACP" + assert provider_label("grok-build") == "Grok Build CLI" assert provider_label("auto") == "Auto" def test_unknown_provider_preserves_original_name(self): @@ -222,6 +224,9 @@ def test_copilot_acp_reuses_copilot_catalog(self): patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]): assert provider_model_ids("copilot-acp") == ["gpt-5.4", "claude-sonnet-4.6"] + def test_grok_build_uses_static_model(self): + assert provider_model_ids("grok-build") == ["grok-build"] + def test_copilot_falls_back_to_curated_defaults_without_stale_opus(self): with patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \ patch("hermes_cli.models._fetch_github_models", return_value=None): diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index c493f91509a7e..70fe5d511a92b 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -4324,6 +4324,36 @@ def test_aiagent_uses_copilot_acp_client(): assert mock_acp_client.call_args.kwargs["args"] == ["--acp", "--stdio"] +def test_aiagent_uses_grok_cli_client(): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI") as mock_openai, + patch("agent.grok_cli_client.GrokCliClient") as mock_grok_client, + ): + grok_client = MagicMock() + mock_grok_client.return_value = grok_client + + agent = AIAgent( + api_key="grok-build", + base_url="grok-cli://local", + provider="grok-build", + command="/usr/local/bin/grok", + args=["--no-memory", "--max-turns", "1"], + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + assert agent.client is grok_client + mock_openai.assert_not_called() + mock_grok_client.assert_called_once() + assert mock_grok_client.call_args.kwargs["base_url"] == "grok-cli://local" + assert mock_grok_client.call_args.kwargs["api_key"] == "grok-build" + assert mock_grok_client.call_args.kwargs["command"] == "/usr/local/bin/grok" + assert mock_grok_client.call_args.kwargs["args"] == ["--no-memory", "--max-turns", "1"] + + def test_quiet_spinner_allowed_with_explicit_print_fn(agent): agent._print_fn = lambda *_a, **_kw: None with patch.object(run_agent.sys.stdout, "isatty", return_value=False): diff --git a/tests/tools/test_obsidian_tools.py b/tests/tools/test_obsidian_tools.py new file mode 100644 index 0000000000000..180a76a9f8e8c --- /dev/null +++ b/tests/tools/test_obsidian_tools.py @@ -0,0 +1,62 @@ +import json + +from tools.obsidian_tools import obsidian_read_tasks_tool + + +def test_obsidian_read_tasks_returns_only_active_tasks_by_default(tmp_path, monkeypatch): + vault = tmp_path / "vault" + vault.mkdir() + (vault / "Tasks.md").write_text( + "# Inbox\n" + "- [ ] Pay bill 📅 2026-05-20\n" + "- [/] Call contractor\n" + "- [x] Old done\n" + "# Home\n" + "- [-] Cancelled thing\n", + encoding="utf-8", + ) + monkeypatch.setenv("OBSIDIAN_VAULT_PATH", str(vault)) + + result = json.loads(obsidian_read_tasks_tool(path="Tasks.md", include_done=False, limit=10)) + + assert result["path"] == "Tasks.md" + assert result["total_tasks"] == 4 + assert result["active_tasks"] == 2 + assert result["matched_tasks"] == 2 + assert result["returned_tasks"] == 2 + assert result["status_counts"] == {"open": 1, "active": 1, "done": 1, "cancelled": 1} + assert [task["status"] for task in result["tasks"]] == ["open", "active"] + assert result["tasks"][0]["line"] == 2 + assert result["tasks"][0]["section"] == "Inbox" + assert result["tasks"][0]["due"] == "2026-05-20" + + +def test_obsidian_read_tasks_include_done_and_limit(tmp_path, monkeypatch): + vault = tmp_path / "vault" + vault.mkdir() + (vault / "Tasks.md").write_text( + "- [ ] One\n" + "- [x] Two\n" + "- [-] Three\n", + encoding="utf-8", + ) + monkeypatch.setenv("OBSIDIAN_VAULT_PATH", str(vault)) + + result = json.loads(obsidian_read_tasks_tool(include_done=True, limit=2)) + + assert result["matched_tasks"] == 3 + assert result["returned_tasks"] == 2 + assert result["truncated"] is True + assert [task["text"] for task in result["tasks"]] == ["One", "Two"] + + +def test_obsidian_read_tasks_rejects_paths_outside_vault(tmp_path, monkeypatch): + vault = tmp_path / "vault" + vault.mkdir() + monkeypatch.setenv("OBSIDIAN_VAULT_PATH", str(vault)) + + result = json.loads(obsidian_read_tasks_tool(path="../outside.md")) + + assert "error" in result + assert "inside Obsidian vault" in result["error"] + assert result["tasks"] == [] diff --git a/tools/obsidian_tools.py b/tools/obsidian_tools.py new file mode 100644 index 0000000000000..8577ea3401e5f --- /dev/null +++ b/tools/obsidian_tools.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Obsidian tools. + +Structured task reads for Markdown task lists in an Obsidian vault. This is +intentionally read-only: agents should use it instead of raw-reading Tasks.md +when they need a compact, line-addressable view of the user's task list. +""" + +from __future__ import annotations + +import json +import os +import re +from collections import OrderedDict +from pathlib import Path +from typing import Any + +from hermes_constants import get_default_hermes_root +from tools.registry import registry + +_TASK_RE = re.compile(r"^(?P\s*)[-*+]\s+\[(?P.)\]\s*(?P.*)$") +_HEADING_RE = re.compile(r"^(?P#{1,6})\s+(?P.+?)\s*$") +_ISO_DATE_RE = re.compile(r"(?<!\d)(20\d{2}-\d{2}-\d{2})(?!\d)") +_DUE_MARKERS = ("📅", "due", "due:", "due::") + +_MARKER_STATUS = { + " ": "open", + "": "open", + "/": "active", + ">": "active", + "x": "done", + "X": "done", + "-": "cancelled", + "~": "cancelled", + "_": "cancelled", +} + +_STATUSES = ("open", "active", "done", "cancelled") + + +def check_obsidian_requirements() -> bool: + """The reader uses only the local filesystem; no external dependency.""" + return True + + +def _load_obsidian_config_path() -> str | None: + """Best-effort config lookup without making the tool import fragile.""" + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + obsidian_cfg = cfg.get("obsidian") or {} + if isinstance(obsidian_cfg, dict): + value = obsidian_cfg.get("vault_path") or obsidian_cfg.get("path") + if value: + return str(value) + except Exception: + return None + return None + + +def _candidate_vault_paths() -> list[Path]: + candidates: list[Path] = [] + + env_path = os.environ.get("OBSIDIAN_VAULT_PATH", "").strip() + if env_path: + candidates.append(Path(env_path).expanduser()) + + cfg_path = _load_obsidian_config_path() + if cfg_path: + candidates.append(Path(cfg_path).expanduser()) + + # In Hermes profile mode HOME may be profile-scoped + # (~/.hermes/profiles/<name>/home). The shared Hermes root still lets us + # recover the real OS home in standard installs: /home/user/.hermes -> + # /home/user. + try: + root = get_default_hermes_root() + if root.name == ".hermes": + candidates.append(root.parent / "Documents" / "Obsidian Vault") + except Exception: + pass + + candidates.append(Path.home() / "Documents" / "Obsidian Vault") + + # Preserve order while dropping duplicates. + deduped: list[Path] = [] + seen: set[str] = set() + for candidate in candidates: + key = str(candidate) + if key not in seen: + seen.add(key) + deduped.append(candidate) + return deduped + + +def _resolve_vault() -> Path: + candidates = _candidate_vault_paths() + for candidate in candidates: + if candidate.exists() and candidate.is_dir(): + return candidate.resolve() + # Return the highest-precedence candidate for a useful error message. + if candidates: + return candidates[0].resolve() + return (Path.home() / "Documents" / "Obsidian Vault").resolve() + + +def _safe_note_path(vault: Path, path: str) -> Path: + requested = (path or "Tasks.md").strip() or "Tasks.md" + raw = Path(requested).expanduser() + if raw.is_absolute(): + candidate = raw.resolve() + else: + candidate = (vault / raw).resolve() + + try: + candidate.relative_to(vault) + except ValueError: + raise ValueError(f"path must stay inside Obsidian vault: {requested}") + return candidate + + +def _status_for_marker(marker: str) -> str: + return _MARKER_STATUS.get(marker, "open") + + +def _extract_due(text: str) -> str | None: + """Extract a likely due date from common Obsidian Tasks syntax.""" + match = _ISO_DATE_RE.search(text) + if not match: + return None + # Prefer dates near a due marker, but fall back to the first ISO date so + # historical task notes remain useful rather than returning nothing. + lower = text.lower() + if any(marker in text or marker in lower for marker in _DUE_MARKERS): + return match.group(1) + return match.group(1) + + +def _new_counts() -> dict[str, int]: + return {status: 0 for status in _STATUSES} + + +def _parse_tasks(content: str, *, include_done: bool) -> tuple[list[dict[str, Any]], dict[str, int], OrderedDict[str, dict[str, int]], int]: + tasks: list[dict[str, Any]] = [] + status_counts = _new_counts() + section_counts: OrderedDict[str, dict[str, int]] = OrderedDict() + current_section = "(top)" + current_level = 0 + total_tasks = 0 + + for line_no, line in enumerate(content.splitlines(), start=1): + heading = _HEADING_RE.match(line) + if heading: + current_level = len(heading.group("hashes")) + current_section = heading.group("title").strip() + section_counts.setdefault(current_section, _new_counts()) + continue + + match = _TASK_RE.match(line) + if not match: + continue + + total_tasks += 1 + marker = match.group("marker") + text = match.group("text").strip() + status = _status_for_marker(marker) + if status not in status_counts: + status = "open" + status_counts[status] += 1 + section_counts.setdefault(current_section, _new_counts())[status] += 1 + + if not include_done and status in {"done", "cancelled"}: + continue + + tasks.append( + { + "line": line_no, + "section": current_section, + "heading_level": current_level, + "status": status, + "marker": marker, + "text": text, + "due": _extract_due(text), + } + ) + + return tasks, status_counts, section_counts, total_tasks + + +def obsidian_read_tasks_tool(path: str = "Tasks.md", include_done: bool = False, limit: int = 50) -> str: + """Read Markdown tasks from an Obsidian note as structured JSON.""" + try: + vault = _resolve_vault() + note_path = _safe_note_path(vault, path) + if not vault.exists(): + return json.dumps( + { + "error": f"Obsidian vault not found: {vault}", + "path": path or "Tasks.md", + "tasks": [], + }, + ensure_ascii=False, + ) + if not note_path.exists() or not note_path.is_file(): + rel = str(note_path.relative_to(vault)) if note_path.is_relative_to(vault) else str(note_path) + return json.dumps( + { + "error": f"Obsidian note not found: {rel}", + "path": rel, + "tasks": [], + }, + ensure_ascii=False, + ) + + content = note_path.read_text(encoding="utf-8") + tasks, status_counts, section_counts, total_tasks = _parse_tasks(content, include_done=bool(include_done)) + + try: + max_items = int(limit) + except (TypeError, ValueError): + max_items = 50 + if max_items < 0: + max_items = 0 + + returned = tasks[:max_items] + rel_path = str(note_path.relative_to(vault)) + active_tasks = status_counts["open"] + status_counts["active"] + matched_tasks = len(tasks) + result = { + "path": rel_path, + "include_done": bool(include_done), + "limit": max_items, + "total_tasks": total_tasks, + "active_tasks": active_tasks, + "matched_tasks": matched_tasks, + "returned_tasks": len(returned), + "truncated": matched_tasks > len(returned), + "status_counts": status_counts, + "section_counts": [ + {"section": section, **counts} + for section, counts in section_counts.items() + if any(counts.values()) + ], + "summary": ( + f"{len(returned)} of {matched_tasks} matching tasks returned from {rel_path}; " + f"{active_tasks} active/open tasks, {status_counts['done']} done, " + f"{status_counts['cancelled']} cancelled." + ), + "tasks": returned, + } + return json.dumps(result, ensure_ascii=False) + except Exception as exc: + return json.dumps({"error": str(exc), "path": path or "Tasks.md", "tasks": []}, ensure_ascii=False) + + +OBSIDIAN_READ_TASKS_SCHEMA = { + "name": "obsidian_read_tasks", + "description": ( + "Read Markdown tasks from an Obsidian note as structured JSON. Use this " + "instead of raw-reading Tasks.md when you need Charlie's task list. By " + "default it returns only active/open tasks, grouped with section and line metadata." + ), + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Vault-relative note path to parse. Defaults to Tasks.md.", + "default": "Tasks.md", + }, + "include_done": { + "type": "boolean", + "description": "Include done and cancelled tasks in the returned tasks array.", + "default": False, + }, + "limit": { + "type": "integer", + "description": "Maximum number of matching tasks to return.", + "default": 50, + "minimum": 0, + }, + }, + "required": [], + }, +} + + +registry.register( + name="obsidian_read_tasks", + toolset="obsidian", + schema=OBSIDIAN_READ_TASKS_SCHEMA, + handler=lambda args, **kw: obsidian_read_tasks_tool( + path=args.get("path", "Tasks.md"), + include_done=args.get("include_done", False), + limit=args.get("limit", 50), + ), + check_fn=check_obsidian_requirements, + emoji="📝", +) diff --git a/toolsets.py b/toolsets.py index 8ec45f11a2fb7..ebfe10de8003e 100644 --- a/toolsets.py +++ b/toolsets.py @@ -35,6 +35,8 @@ "terminal", "process", # File manipulation "read_file", "write_file", "patch", "search_files", + # Obsidian structured note/task readers + "obsidian_read_tasks", # Vision + image generation "vision_analyze", "image_generate", # Skills @@ -176,6 +178,12 @@ "tools": ["read_file", "write_file", "patch", "search_files"], "includes": [] }, + + "obsidian": { + "description": "Obsidian vault tools: structured task reads from Markdown notes", + "tools": ["obsidian_read_tasks"], + "includes": [] + }, "tts": { "description": "Text-to-speech: convert text to audio with Edge TTS (free), ElevenLabs, OpenAI, or xAI", diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 80eaf3589ca23..75dbcc822c69c 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -114,6 +114,7 @@ Good defaults: | **NVIDIA NIM** | Nemotron models via build.nvidia.com or local NIM | Set `NVIDIA_API_KEY` (optional: `NVIDIA_BASE_URL`) | | **GitHub Copilot** | GitHub Copilot subscription (GPT-5.x, Claude, Gemini, etc.) | OAuth via `hermes model`, or `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` | | **GitHub Copilot ACP** | Copilot ACP agent backend (spawns local `copilot` CLI) | `hermes model` (requires `copilot` CLI + `copilot login`) | +| **Grok Build CLI** | Grok Build subscription backend (spawns local `grok` CLI) | `hermes model` (requires Grok CLI + `grok login`) | | **Vercel AI Gateway** | Vercel AI Gateway routing | Set `AI_GATEWAY_API_KEY` | | **Custom Endpoint** | VLLM, SGLang, Ollama, or any OpenAI-compatible API | Set base URL + API key | diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 248d17c5fac7d..7810793055217 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -18,6 +18,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro | **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) | | **GitHub Copilot** | `hermes model` (OAuth device code flow, `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token`) | | **GitHub Copilot ACP** | `hermes model` (spawns local `copilot --acp --stdio`) | +| **Grok Build CLI** | `hermes model` (spawns local `grok` CLI; requires an existing Grok Build login) | | **Anthropic** | `hermes model` (Claude Max + extra usage credits via OAuth; also supports Anthropic API key or manual setup-token — see note below) | | **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` | | **NovitaAI** | `NOVITA_API_KEY` in `~/.hermes/.env` (provider: `novita`, 200+ models, Model API, Agent Sandbox, GPU Cloud) | @@ -250,18 +251,37 @@ hermes chat --provider copilot-acp --model copilot-acp # Requires the GitHub Copilot CLI in PATH and an existing `copilot login` session ``` -**Permanent config:** +**`grok-build` — Grok Build CLI backend**. Spawns the local Grok CLI in headless mode and sends prompts via a temporary prompt file: + +```bash +hermes chat --provider grok-build --model grok-build +# Requires the Grok CLI in PATH or ~/.grok/bin and an existing `grok login` session +``` + +Hermes auto-detects `~/.grok/bin/grok`, `~/.local/bin/grok`, or `grok` in `PATH`. Override the command with `HERMES_GROK_BUILD_COMMAND` or `GROK_CLI_PATH`. By default Hermes runs Grok Build with `--no-memory --disable-web-search --max-turns 1 --output-format plain --effort xhigh`; override with `HERMES_GROK_BUILD_ARGS` or just the effort with `HERMES_GROK_BUILD_EFFORT`. + +**Copilot permanent config:** ```yaml model: provider: "copilot" default: "gpt-5.4" ``` +**Grok Build permanent config:** +```yaml +model: + provider: "grok-build" + default: "grok-build" +``` + | Environment variable | Description | |---------------------|-------------| | `COPILOT_GITHUB_TOKEN` | GitHub token for Copilot API (first priority) | | `HERMES_COPILOT_ACP_COMMAND` | Override the Copilot CLI binary path (default: `copilot`) | | `HERMES_COPILOT_ACP_ARGS` | Override ACP args (default: `--acp --stdio`) | +| `HERMES_GROK_BUILD_COMMAND` / `GROK_CLI_PATH` | Override the Grok CLI binary path | +| `HERMES_GROK_BUILD_ARGS` | Override Grok CLI args for the `grok-build` provider | +| `HERMES_GROK_BUILD_EFFORT` | Override the default Grok Build effort when args are not set | ### First-Class API-Key Providers @@ -1446,7 +1466,7 @@ fallback_model: When activated, the fallback swaps the model and provider mid-session without losing your conversation. The chain is tried entry-by-entry; activation is one-shot per session. -Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `ai-gateway`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`. +Supported providers: `openrouter`, `nous`, `openai-codex`, `grok-build`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `ai-gateway`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`. :::tip Fallback is configured exclusively through `config.yaml` — or interactively via `hermes fallback`. For full details on when it triggers, how the chain advances, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/docs/user-guide/features/fallback-providers). diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 3b5b7d2e925a2..c289e72c5ba6b 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -92,7 +92,7 @@ Common options: | `-q`, `--query "..."` | One-shot, non-interactive prompt. | | `-m`, `--model <model>` | Override the model for this run. | | `-t`, `--toolsets <csv>` | Enable a comma-separated set of toolsets. | -| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `huggingface`, `novita`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). | +| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `grok-build`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `huggingface`, `novita`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). | | `-s`, `--skills <name>` | Preload one or more skills for the session (can be repeated or comma-separated). | | `-v`, `--verbose` | Verbose output. | | `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 56fe8a1371512..ab729a7fbd4b4 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -105,7 +105,10 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | Variable | Description | |----------|-------------| -| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `custom`, `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `novita`, `gemini`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth` (browser OAuth login — no API key required; see [MiniMax OAuth guide](../guides/minimax-oauth.md)), `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (browser OAuth login for SuperGrok subscribers — no API key required; see [xAI Grok OAuth guide](../guides/xai-grok-oauth.md)), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `tencent-tokenhub` (default: `auto`) | +| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `custom`, `openrouter`, `nous`, `openai-codex`, `grok-build`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `novita`, `gemini`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth` (browser OAuth login — no API key required; see [MiniMax OAuth guide](../guides/minimax-oauth.md)), `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (browser OAuth login for SuperGrok subscribers — no API key required; see [xAI Grok OAuth guide](../guides/xai-grok-oauth.md)), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `tencent-tokenhub` (default: `auto`) | +| `HERMES_GROK_BUILD_COMMAND` / `GROK_CLI_PATH` | Override the `grok` executable used by the `grok-build` provider. Defaults to `~/.grok/bin/grok`, `~/.local/bin/grok`, then `grok` in `PATH`. | +| `HERMES_GROK_BUILD_ARGS` | Override Grok Build CLI arguments. Defaults to `--no-memory --disable-web-search --max-turns 1 --output-format plain --effort xhigh`. | +| `HERMES_GROK_BUILD_EFFORT` | Override only the default Grok Build effort (`low`, `medium`, `high`, `xhigh`, or `max`) when `HERMES_GROK_BUILD_ARGS` is not set. | | `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) | | `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL | | `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) | diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md index 72528796d5574..6067f3825b1e5 100644 --- a/website/docs/user-guide/features/fallback-providers.md +++ b/website/docs/user-guide/features/fallback-providers.md @@ -53,6 +53,7 @@ Both `provider` and `model` are **required**. If either is missing, the fallback | OpenAI Codex | `openai-codex` | `hermes model` (ChatGPT OAuth) | | GitHub Copilot | `copilot` | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, or `GITHUB_TOKEN` | | GitHub Copilot ACP | `copilot-acp` | External process (editor integration) | +| Grok Build CLI | `grok-build` | External process (`grok` CLI + Grok Build login) | | Anthropic | `anthropic` | `ANTHROPIC_API_KEY` or Claude Code credentials | | z.ai / GLM | `zai` | `GLM_API_KEY` | | Kimi / Moonshot | `kimi-coding` | `KIMI_API_KEY` |