Files
mlflow--mlflow/dev/dev_stubs/claude_cli.py
T
2026-07-13 13:22:34 +08:00

110 lines
4.0 KiB
Python

"""Credential-free stub `claude` CLI for reviewing provider-gated Assistant UI.
The MLflow Assistant's "Claude Code" provider only reveals its chat panel after
an auth probe succeeds: it shells out to
``claude -p hi --max-turns 1 --output-format json`` and unlocks the UI when that
exits 0 (see ``mlflow/assistant/providers/claude_code.py``). When the dev server
runs without ``ANTHROPIC_API_KEY`` -- the CI ui-review bot (to keep secrets out
of PR backend code), or a local dev who hasn't installed/authed the real CLI --
the provider reports "not authenticated" and the panel never renders, leaving
provider-gated UI (e.g. restore-chat-on-reload) unreviewable.
This script stands in for ``claude``. ``run_dev_server.py --stub-providers claude``
wraps it in a ``claude`` shim and prepends its directory to the dev server's PATH
(only the dev server process and its children; a real ``claude`` elsewhere on the
machine -- e.g. the ui-review bot's own review step -- is untouched).
It never contacts Anthropic, so it costs nothing, needs no credentials, and is
deterministic:
- ``--output-format json`` (the auth probe): print one success result and exit 0.
- ``--output-format stream-json`` (a live chat turn): emit canned stream-json
events -- an assistant text message plus a closing result -- so the chat panel
can be exercised end to end and its messages persisted for restore-on-reload.
The reply is clearly labeled synthetic so reviewers don't mistake it for a real
model response. Real provider behavior stays covered by unit/integration tests.
"""
from __future__ import annotations
import argparse
import json
import sys
import uuid
from typing import Any
STUB_REPLY = (
"This is a synthetic reply from the MLflow dev stub Claude CLI. The real "
"Claude Code provider is replaced so the Assistant chat panel can be reviewed "
"without credentials or LLM calls. No model was invoked to produce this message."
)
STUB_MODEL = "mlflow-dev-stub"
def _emit(obj: dict[str, Any]) -> None:
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
def _result_event(session_id: str) -> dict[str, Any]:
return {
"type": "result",
"subtype": "success",
"is_error": False,
"result": STUB_REPLY,
"session_id": session_id,
"duration_ms": 1,
"num_turns": 1,
"total_cost_usd": 0.0,
"usage": {
"input_tokens": 8,
"output_tokens": 24,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
},
}
def main(argv: list[str]) -> int:
# The real `claude` CLI takes many flags; parse only the few the stub needs and
# let parse_known_args drop the rest. add_help/allow_abbrev are off so `-p`,
# `--verbose`, etc. fall through to the ignored extras rather than being matched.
parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
parser.add_argument("--version", action="store_true")
parser.add_argument("--output-format", default="text")
parser.add_argument("--resume", default=None)
args, _ = parser.parse_known_args(argv)
if args.version:
print(f"0.0.0 ({STUB_MODEL})")
return 0
# Reuse the resume id so a continued conversation keeps a stable session.
session_id = args.resume or f"mlflow-dev-stub-{uuid.uuid4().hex[:12]}"
if args.output_format == "stream-json":
_emit({
"type": "system",
"subtype": "init",
"session_id": session_id,
"model": STUB_MODEL,
"tools": [],
})
_emit({
"type": "assistant",
"message": {"role": "assistant", "content": [{"type": "text", "text": STUB_REPLY}]},
})
_emit(_result_event(session_id))
return 0
# Auth probe (`--output-format json`) and any other invocation: the provider
# only checks the exit code, but emit a valid result object for good measure.
_emit(_result_event(session_id))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))