feat: add --use-cli backend for Claude monthly plan users
Adds a second annotation backend that shells out to the 'claude' CLI
(Claude Code) instead of calling the Anthropic SDK. This allows users
with a Claude.ai monthly subscription to run the annotation script
without needing an API key.
The SDK backend (default) still uses prompt caching on the proto context
block for cheaper repeated/resumed runs. The CLI backend skips caching
but works out of the box with a Claude.ai account.
Usage:
# Monthly plan (no API key)
python scripts/generate_tool_annotations.py --fetch-from-gitlab --use-cli
# API key (with prompt caching)
python scripts/generate_tool_annotations.py --fetch-from-gitlab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -412,16 +412,61 @@ def _build_proto_context(messages: dict[str, ProtoMessage]) -> str:
|
|||||||
return "\n".join(sections)
|
return "\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
def call_claude(
|
def _filter_command_messages(
|
||||||
|
messages: dict[str, ProtoMessage], existing: dict, resume: bool
|
||||||
|
) -> tuple[dict[str, ProtoMessage], dict[str, ProtoMessage]]:
|
||||||
|
"""Return (all_messages_for_context, todo_commands) after applying --resume filter."""
|
||||||
|
command_messages = {
|
||||||
|
name: msg
|
||||||
|
for name, msg in messages.items()
|
||||||
|
if "_commands" in msg.source_file and not msg.is_response
|
||||||
|
}
|
||||||
|
if resume:
|
||||||
|
already_done = set(existing.get("annotations", {}).keys())
|
||||||
|
todo = {n: m for n, m in command_messages.items() if n not in already_done}
|
||||||
|
print(f" Resuming: {len(already_done)} already annotated, {len(todo)} remaining")
|
||||||
|
else:
|
||||||
|
todo = command_messages
|
||||||
|
return command_messages, todo
|
||||||
|
|
||||||
|
|
||||||
|
def _build_full_prompt(proto_context: str, target_names: list[str]) -> str:
|
||||||
|
"""Build the complete prompt text used by both the SDK and CLI backends."""
|
||||||
|
return (
|
||||||
|
_SYSTEM_PROMPT
|
||||||
|
+ "\n\n## KiCad IPC API — proto definitions\n\n"
|
||||||
|
+ proto_context
|
||||||
|
+ "\n\n## Annotation request\n\n"
|
||||||
|
"Generate MCP annotations for the following request messages:\n"
|
||||||
|
+ "\n".join(f"- {n}" for n in target_names)
|
||||||
|
+ "\n\nReturn only the JSON object described in your instructions."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_response(raw: str) -> dict:
|
||||||
|
"""Parse a Claude text response to a JSON dict, stripping markdown fences."""
|
||||||
|
raw = raw.strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = re.sub(r"^```[a-z]*\n?", "", raw).rstrip("`").strip()
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
print(f"ERROR: Claude returned invalid JSON: {exc}", file=sys.stderr)
|
||||||
|
print("--- raw response (first 2000 chars) ---", file=sys.stderr)
|
||||||
|
print(raw[:2000], file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def call_claude_sdk(
|
||||||
messages: dict[str, ProtoMessage],
|
messages: dict[str, ProtoMessage],
|
||||||
model: str,
|
model: str,
|
||||||
existing: dict,
|
existing: dict,
|
||||||
resume: bool,
|
resume: bool,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Call the Claude API to generate annotations for all request messages.
|
Annotate messages via the Anthropic Python SDK (requires ANTHROPIC_API_KEY).
|
||||||
|
|
||||||
Uses prompt caching on the proto context block so repeated runs against
|
Uses prompt caching on the static proto context block so repeated runs against
|
||||||
the same proto definitions only bill the small annotation-request portion.
|
the same proto definitions only bill the small annotation-request portion.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -431,33 +476,18 @@ def call_claude(
|
|||||||
|
|
||||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
sys.exit("ANTHROPIC_API_KEY environment variable is not set")
|
sys.exit("ANTHROPIC_API_KEY is not set. Use --use-cli to call Claude Code instead.")
|
||||||
|
|
||||||
client = anthropic.Anthropic(api_key=api_key)
|
client = anthropic.Anthropic(api_key=api_key)
|
||||||
|
|
||||||
# Decide which messages need annotation
|
_, todo = _filter_command_messages(messages, existing, resume)
|
||||||
# Command messages live in *_commands.proto files; type/settings messages in *_types.proto
|
|
||||||
# and base_types.proto are data structures, not callable commands.
|
|
||||||
request_messages = {
|
|
||||||
name: msg
|
|
||||||
for name, msg in messages.items()
|
|
||||||
if "_commands" in msg.source_file and not msg.is_response
|
|
||||||
}
|
|
||||||
if resume:
|
|
||||||
already_done = set(existing.get("annotations", {}).keys())
|
|
||||||
todo = {n: m for n, m in request_messages.items() if n not in already_done}
|
|
||||||
print(f" Resuming: {len(already_done)} already annotated, {len(todo)} remaining")
|
|
||||||
else:
|
|
||||||
todo = request_messages
|
|
||||||
|
|
||||||
if not todo:
|
if not todo:
|
||||||
print(" Nothing to annotate.")
|
print(" Nothing to annotate.")
|
||||||
return existing
|
return existing
|
||||||
|
|
||||||
proto_context = _build_proto_context(messages)
|
proto_context = _build_proto_context(messages)
|
||||||
target_names = sorted(todo.keys())
|
target_names = sorted(todo.keys())
|
||||||
|
print(f" Sending {len(target_names)} messages to {model} via SDK ...")
|
||||||
print(f" Sending {len(target_names)} messages to {model} for annotation ...")
|
|
||||||
|
|
||||||
response = client.messages.create(
|
response = client.messages.create(
|
||||||
model=model,
|
model=model,
|
||||||
@@ -467,16 +497,12 @@ def call_claude(
|
|||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": [
|
"content": [
|
||||||
# Cache the large, static proto context
|
# Cache the large, static proto context block
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": (
|
"text": "## KiCad IPC API — proto definitions\n\n" + proto_context,
|
||||||
"## KiCad IPC API — proto definitions\n\n"
|
|
||||||
+ proto_context
|
|
||||||
),
|
|
||||||
"cache_control": {"type": "ephemeral"},
|
"cache_control": {"type": "ephemeral"},
|
||||||
},
|
},
|
||||||
# Small, non-cached request specifying what to annotate
|
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": (
|
"text": (
|
||||||
@@ -491,7 +517,6 @@ def call_claude(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Report cache stats when available
|
|
||||||
usage = response.usage
|
usage = response.usage
|
||||||
if hasattr(usage, "cache_creation_input_tokens"):
|
if hasattr(usage, "cache_creation_input_tokens"):
|
||||||
print(
|
print(
|
||||||
@@ -501,26 +526,89 @@ def call_claude(
|
|||||||
f"output: {usage.output_tokens}"
|
f"output: {usage.output_tokens}"
|
||||||
)
|
)
|
||||||
|
|
||||||
raw = response.content[0].text.strip()
|
new_annotations = _parse_response(response.content[0].text)
|
||||||
|
|
||||||
# Strip markdown fences if the model wrapped the output anyway
|
|
||||||
if raw.startswith("```"):
|
|
||||||
raw = re.sub(r"^```[a-z]*\n?", "", raw).rstrip("`").strip()
|
|
||||||
|
|
||||||
try:
|
|
||||||
new_annotations: dict = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
print(f"ERROR: Claude returned invalid JSON: {exc}", file=sys.stderr)
|
|
||||||
print("--- raw response ---", file=sys.stderr)
|
|
||||||
print(raw[:2000], file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Merge into existing output
|
|
||||||
result = dict(existing)
|
result = dict(existing)
|
||||||
result.setdefault("annotations", {}).update(new_annotations)
|
result.setdefault("annotations", {}).update(new_annotations)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def call_claude_cli(
|
||||||
|
messages: dict[str, ProtoMessage],
|
||||||
|
model: str,
|
||||||
|
existing: dict,
|
||||||
|
resume: bool,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Annotate messages by shelling out to the ``claude`` CLI (Claude Code).
|
||||||
|
|
||||||
|
Works with a Claude.ai monthly subscription — no API key required.
|
||||||
|
The ``claude`` binary must be on PATH (install Claude Code from claude.ai/code).
|
||||||
|
|
||||||
|
Note: prompt caching is not available via the CLI; the full context is sent
|
||||||
|
each time. Use --resume between interrupted runs to avoid redundant work.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
claude_bin = shutil.which("claude")
|
||||||
|
if not claude_bin:
|
||||||
|
sys.exit(
|
||||||
|
"claude CLI not found on PATH.\n"
|
||||||
|
"Install Claude Code from https://claude.ai/code, then re-run."
|
||||||
|
)
|
||||||
|
|
||||||
|
_, todo = _filter_command_messages(messages, existing, resume)
|
||||||
|
if not todo:
|
||||||
|
print(" Nothing to annotate.")
|
||||||
|
return existing
|
||||||
|
|
||||||
|
proto_context = _build_proto_context(messages)
|
||||||
|
target_names = sorted(todo.keys())
|
||||||
|
print(f" Sending {len(target_names)} messages to claude CLI ...")
|
||||||
|
|
||||||
|
prompt = _build_full_prompt(proto_context, target_names)
|
||||||
|
|
||||||
|
cmd = [claude_bin, "--output-format", "text", "-p", prompt]
|
||||||
|
if model:
|
||||||
|
cmd += ["--model", model]
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
sys.exit("ERROR: claude CLI timed out after 5 minutes.")
|
||||||
|
except FileNotFoundError:
|
||||||
|
sys.exit(f"ERROR: could not execute {claude_bin}")
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(f"ERROR: claude CLI exited {proc.returncode}", file=sys.stderr)
|
||||||
|
if proc.stderr:
|
||||||
|
print(proc.stderr[:1000], file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
new_annotations = _parse_response(proc.stdout)
|
||||||
|
result = dict(existing)
|
||||||
|
result.setdefault("annotations", {}).update(new_annotations)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def call_claude(
|
||||||
|
messages: dict[str, ProtoMessage],
|
||||||
|
model: str,
|
||||||
|
existing: dict,
|
||||||
|
resume: bool,
|
||||||
|
use_cli: bool,
|
||||||
|
) -> dict:
|
||||||
|
"""Dispatch to the appropriate Claude backend."""
|
||||||
|
if use_cli:
|
||||||
|
return call_claude_cli(messages, model, existing, resume)
|
||||||
|
return call_claude_sdk(messages, model, existing, resume)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Output helpers
|
# Output helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -617,6 +705,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
help="Parse proto files and list what would be annotated; do not call the API.",
|
help="Parse proto files and list what would be annotated; do not call the API.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
backend = p.add_mutually_exclusive_group()
|
||||||
|
backend.add_argument(
|
||||||
|
"--use-cli",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Use the 'claude' CLI (Claude Code) instead of the SDK. "
|
||||||
|
"Works with a Claude.ai monthly plan — no API key needed. "
|
||||||
|
"Requires the 'claude' binary on PATH."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
backend.add_argument(
|
||||||
|
"--use-sdk",
|
||||||
|
action="store_true",
|
||||||
|
default=True,
|
||||||
|
help="Use the Anthropic Python SDK (requires ANTHROPIC_API_KEY). This is the default.",
|
||||||
|
)
|
||||||
|
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
@@ -655,7 +760,13 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||||||
|
|
||||||
# ── annotate ─────────────────────────────────────────────────────────────
|
# ── annotate ─────────────────────────────────────────────────────────────
|
||||||
existing = load_existing(args.output) if args.resume else {"annotations": {}}
|
existing = load_existing(args.output) if args.resume else {"annotations": {}}
|
||||||
result = call_claude(messages, args.model, existing, resume=args.resume)
|
result = call_claude(
|
||||||
|
messages,
|
||||||
|
args.model,
|
||||||
|
existing,
|
||||||
|
resume=args.resume,
|
||||||
|
use_cli=args.use_cli,
|
||||||
|
)
|
||||||
|
|
||||||
# ── write ────────────────────────────────────────────────────────────────
|
# ── write ────────────────────────────────────────────────────────────────
|
||||||
write_output(result, args.output, kicad_ref)
|
write_output(result, args.output, kicad_ref)
|
||||||
|
|||||||
Reference in New Issue
Block a user