diff --git a/markitect/cli.py b/markitect/cli.py index d675b18a..fcecbe01 100644 --- a/markitect/cli.py +++ b/markitect/cli.py @@ -7095,10 +7095,12 @@ try: except ImportError: pass # Prompts module not available -# Register helper Q&A command +# Register LLM commands (llm-helper, llm-catalog, llm-check) try: - from markitect.helper.cli import helper_command + from markitect.helper.cli import helper_command, llm_catalog, llm_check cli.add_command(helper_command) + cli.add_command(llm_catalog) + cli.add_command(llm_check) except ImportError: pass # Helper module not available diff --git a/markitect/helper/cli.py b/markitect/helper/cli.py index 769cee5a..0db11922 100644 --- a/markitect/helper/cli.py +++ b/markitect/helper/cli.py @@ -1,13 +1,18 @@ """ -CLI command for the markitect helper. +CLI commands for markitect LLM operations: llm-helper, llm-catalog, llm-check. """ +import json import os +import subprocess import sys +import time import click +from tabulate import tabulate from markitect.helper.knowledge import collect_knowledge +from markitect.llm.config import find_project_root, resolve_api_key DEFAULT_PROVIDER = "openrouter" DEFAULT_MODEL = "openrouter/aurora-alpha" @@ -20,8 +25,68 @@ SYSTEM_PROMPT_TEMPLATE = ( "{knowledge}" ) +_PROVIDER_INFO = { + "openrouter": { + "default_model": "anthropic/claude-sonnet-4", + "env_var": "OPENROUTER_API_KEY", + "key_file": "apikey-openrouter.txt", + }, + "claude-code": { + "default_model": None, + "env_var": None, + "key_file": None, + }, + "gemini": { + "default_model": "gemini-2.5-flash", + "env_var": "GEMINI_API_KEY", + "key_file": "apikey-geminifree.txt", + }, + "openai": { + "default_model": "gpt-4.1-mini", + "env_var": "OPENAI_API_KEY", + "key_file": "apikey-chatgpt.txt", + }, +} -@click.command("helper") + +def _probe_key_status(provider: str, info: dict) -> str: + """Return a human-readable key status string for a provider.""" + if provider == "claude-code": + try: + subprocess.run( + ["claude", "--version"], + capture_output=True, + timeout=5, + ) + return "ok (claude --version)" + except (FileNotFoundError, subprocess.TimeoutExpired): + return "not found (claude CLI)" + + env_var = info["env_var"] + key_file = info["key_file"] + root = find_project_root() + + # Check env var first. + if env_var and os.environ.get(env_var, "").strip(): + return "found (env)" + + # Check key file. + if key_file and root: + key_path = root / key_file + try: + if key_path.read_text().strip(): + return "found (file)" + except OSError: + pass + + return "not found" + + +# --------------------------------------------------------------------------- +# llm-helper (renamed from helper) +# --------------------------------------------------------------------------- + +@click.command("llm-helper") @click.argument("question", nargs=-1, required=True) @click.option( "--provider", "-p", @@ -43,9 +108,9 @@ def helper_command(question, provider, model): \b Examples: - markitect helper "What is markitect?" - markitect helper How do schemas work - markitect helper -m anthropic/claude-sonnet-4 "Explain templates" + markitect llm-helper "What is markitect?" + markitect llm-helper How do schemas work + markitect llm-helper -m anthropic/claude-sonnet-4 "Explain templates" """ from markitect.llm import create_adapter from markitect.llm.exceptions import LLMConfigurationError, LLMError @@ -99,3 +164,108 @@ def helper_command(question, provider, model): sys.exit(1) click.echo(response.content) + + +# --------------------------------------------------------------------------- +# llm-catalog +# --------------------------------------------------------------------------- + +@click.command("llm-catalog") +@click.option( + "--format", "output_format", + type=click.Choice(["table", "json"]), + default="table", + show_default=True, + help="Output format.", +) +def llm_catalog(output_format): + """Show all known LLM providers with their default model and key status.""" + rows = [] + for provider, info in _PROVIDER_INFO.items(): + key_status = _probe_key_status(provider, info) + rows.append({ + "provider": provider, + "default_model": info["default_model"] or "(none, uses CLI)", + "env_var": info["env_var"] or "\u2014", + "key_file": info["key_file"] or "\u2014", + "key_status": key_status, + }) + + if output_format == "json": + click.echo(json.dumps(rows, indent=2)) + else: + headers = { + "provider": "Provider", + "default_model": "Default Model", + "env_var": "API Key Env Var", + "key_file": "Key File", + "key_status": "Key Status", + } + click.echo(tabulate(rows, headers=headers, tablefmt="simple")) + + +# --------------------------------------------------------------------------- +# llm-check +# --------------------------------------------------------------------------- + +@click.command("llm-check") +@click.option( + "--provider", "-p", + default=DEFAULT_PROVIDER, + type=click.Choice(["openrouter", "claude-code", "gemini", "openai"]), + show_default=True, + help="LLM provider to check.", +) +@click.option( + "--model", "-m", + default=None, + help=( + f"Model name. Overrides {MODEL_ENV_VAR} env var and the default " + f"({DEFAULT_MODEL})." + ), +) +def llm_check(provider, model): + """Send a minimal prompt to verify a provider is reachable and responding.""" + from markitect.llm import create_adapter + from markitect.llm.exceptions import LLMConfigurationError, LLMError + from markitect.prompts.execution.models import RunConfig + + resolved_model = model or os.environ.get(MODEL_ENV_VAR) or DEFAULT_MODEL + + click.echo(f"Checking {provider} ({resolved_model})...") + + try: + adapter = create_adapter( + provider=provider, + model=resolved_model, + ) + except LLMConfigurationError as exc: + click.echo(f"ERROR \u2014 Configuration: {exc}", err=True) + sys.exit(1) + + config = RunConfig( + model_name=resolved_model, + max_tokens=16, + temperature=0.0, + ) + + start = time.monotonic() + try: + response = adapter.execute_prompt("Reply with only the word OK.", config) + except LLMError as exc: + elapsed = time.monotonic() - start + click.echo(f"ERROR \u2014 LLM error after {elapsed:.1f}s: {exc}", err=True) + sys.exit(1) + except Exception as exc: + elapsed = time.monotonic() - start + click.echo(f"ERROR \u2014 Unexpected error after {elapsed:.1f}s: {exc}", err=True) + sys.exit(1) + + elapsed = time.monotonic() - start + resp_model = response.metadata.get("model", resolved_model) + total_tokens = sum(response.usage.values()) if response.usage else "?" + + click.echo( + f"OK \u2014 response in {elapsed:.1f}s, model: {resp_model}, " + f"tokens: {total_tokens}" + )