"""Kayba CLI — commands for the Kayba hosted API.""" from __future__ import annotations import importlib.resources import json import re import sys import time from datetime import datetime, timezone from pathlib import Path from typing import Optional import click from ace.cli.client import KaybaClient, KaybaAPIError # Shared options applied to every command. _api_key_option = click.option( "--api-key", envvar="KAYBA_API_KEY", help="Kayba API key (or set KAYBA_API_KEY).", ) _base_url_option = click.option( "--base-url", envvar="KAYBA_API_URL", help="API base URL (default: https://use.kayba.ai/api).", ) MAX_TRACE_CHARS = 350_000 PROMPT_BLOCK_START = "" PROMPT_BLOCK_END = "" PROMPT_INSTALL_TARGETS = { "universal": ("AGENTS.md", "most coding agents"), "codex": ("AGENTS.md", "Codex"), "windsurf": ("AGENTS.md", "Windsurf"), "claude-code": ("CLAUDE.md", "Claude Code"), "cursor": (".cursorrules", "Cursor"), } def _client(api_key: Optional[str], base_url: Optional[str]) -> KaybaClient: """Build a KaybaClient, surfacing auth errors as click failures.""" try: return KaybaClient(api_key=api_key, base_url=base_url) except KaybaAPIError as exc: raise click.ClickException(str(exc)) def _detect_file_type(filename: str) -> str: """Infer fileType from extension.""" ext = Path(filename).suffix.lower() return {"md": "md", "markdown": "md", "json": "json", "jsonl": "json", "toon": "json"}.get( ext.lstrip("."), "txt", ) # --------------------------------------------------------------------------- # upload # --------------------------------------------------------------------------- @click.command() @click.argument("paths", nargs=-1) @click.option( "--type", "file_type", type=click.Choice(["md", "json", "txt"]), default=None, help="Force file type (auto-detected from extension by default).", ) @_api_key_option @_base_url_option def upload(paths, file_type, api_key, base_url): """Upload trace files to Kayba. PATHS can be files, directories, or '-' for stdin. Directories are walked recursively. """ client = _client(api_key, base_url) traces = _collect_upload_traces(paths, file_type) if not traces: raise click.ClickException("No traces to upload.") _warn_large_trace_batch(traces) try: result = client.upload_traces(traces) except KaybaAPIError as exc: raise click.ClickException(str(exc)) count = result.get("count", len(result.get("traces", []))) click.echo(f"Uploaded {count} trace(s).") for t in result.get("traces", []): click.echo(f" {t['id']} {t['filename']}") def _add_file(traces: list, path: Path, forced_type: Optional[str]): content = path.read_text(encoding="utf-8", errors="replace") if len(content) > MAX_TRACE_CHARS: click.echo( f"Skipping {path.name}: {len(content)} chars exceeds the Kayba API " f"limit of {MAX_TRACE_CHARS}. Split or trim the trace and re-upload.", err=True, ) return False ft = forced_type or _detect_file_type(path.name) traces.append({"filename": path.name, "content": content, "fileType": ft}) return True def _collect_upload_traces( paths: tuple[str, ...], forced_type: Optional[str], ) -> list[dict[str, str]]: """Collect uploadable traces from files, directories, or stdin.""" traces: list[dict[str, str]] = [] items = list(paths) if paths else ["-"] for item in items: if item == "-": content = sys.stdin.read() if len(content) > MAX_TRACE_CHARS: click.echo( f"Skipping stdin.txt: {len(content)} chars exceeds the Kayba API " f"limit of {MAX_TRACE_CHARS}. Split or trim the trace and re-upload.", err=True, ) continue ft = forced_type or "txt" traces.append({"filename": "stdin.txt", "content": content, "fileType": ft}) continue p = Path(item) if p.is_dir(): for child in sorted(p.rglob("*")): if child.is_file(): _add_file(traces, child, forced_type) elif p.is_file(): _add_file(traces, p, forced_type) else: click.echo(f"Warning: skipping {item} (not found)", err=True) return traces def _warn_large_trace_batch(traces: list[dict[str, str]]) -> None: """Warn once when a batch contains very large trace files.""" oversized = sum(1 for trace in traces if len(trace["content"]) > MAX_TRACE_CHARS) if oversized: click.echo( "Warning: " f"{oversized} trace(s) exceed {MAX_TRACE_CHARS} chars; the CLI will chunk uploads " "into smaller requests, but very large individual files may still be " "rejected.", err=True, ) # --------------------------------------------------------------------------- # traces # --------------------------------------------------------------------------- @click.group() def traces(): """List, view, upload, and delete traces.""" pass @traces.command("list") @click.option("--json", "as_json", is_flag=True, help="Output raw JSON.") @_api_key_option @_base_url_option def traces_list(as_json, api_key, base_url): """List uploaded traces.""" client = _client(api_key, base_url) try: result = client.list_traces() except KaybaAPIError as exc: raise click.ClickException(str(exc)) items = result.get("traces", []) if as_json: click.echo(json.dumps(items, indent=2)) return if not items: click.echo(_no_traces_message()) return # Table header click.echo( f" {'ID':<36} {'Filename':<40} {'Type':<6} {'Size':>8} {'Uploaded'}" ) click.echo(f" {'-' * 36} {'-' * 40} {'-' * 6} {'-' * 8} {'-' * 10}") for t in items: tid = t.get("id", "?") fname = t.get("filename", "?") ftype = t.get("fileType", t.get("type", "?")) size = _format_size(t.get("size", 0)) age = _format_age(t.get("uploadedAt", "")) click.echo(f" {tid:<36} {fname:<40} {ftype:<6} {size:>8} {age}") click.echo(f"\n {len(items)} trace(s)") @traces.command("show") @click.argument("trace_id") @click.option("--json", "as_json", is_flag=True, help="Output raw JSON.") @click.option("--meta", is_flag=True, help="Show only metadata, no content.") @_api_key_option @_base_url_option def traces_show(trace_id, as_json, meta, api_key, base_url): """View a trace.""" client = _client(api_key, base_url) try: result = client.get_trace(trace_id) except KaybaAPIError as exc: raise click.ClickException(str(exc)) if as_json: click.echo(json.dumps(result, indent=2)) return # Metadata header click.echo(f"ID: {result.get('id', '?')}") click.echo(f"Filename: {result.get('filename', '?')}") click.echo(f"Type: {result.get('fileType', result.get('type', '?'))}") click.echo(f"Size: {_format_size(result.get('size', 0))}") click.echo(f"Uploaded: {result.get('uploadedAt', '?')}") if not meta: content = result.get("content", "") if content: click.echo(f"\n{'─' * 60}") click.echo(content) @traces.command("delete") @click.argument("trace_ids", nargs=-1, required=True) @click.option("--force", is_flag=True, help="Skip confirmation prompt.") @_api_key_option @_base_url_option def traces_delete(trace_ids, force, api_key, base_url): """Delete one or more traces.""" if not force and sys.stdin.isatty(): count = len(trace_ids) if not click.confirm(f"Delete {count} trace(s)?"): click.echo("Aborted.") return client = _client(api_key, base_url) try: result = client.delete_traces(list(trace_ids)) except KaybaAPIError as exc: raise click.ClickException(str(exc)) deleted = result.get("deleted", []) errors = result.get("errors", []) for tid in deleted: click.echo(f" Deleted {tid}") for err in errors: click.echo(f" Error deleting {err['id']}: {err['error']}", err=True) if errors: raise click.ClickException(f"{len(errors)} deletion(s) failed.") @traces.command("upload") @click.argument("paths", nargs=-1) @click.option( "--type", "file_type", type=click.Choice(["md", "json", "txt"]), default=None, help="Force file type (auto-detected from extension by default).", ) @_api_key_option @_base_url_option def traces_upload(paths, file_type, api_key, base_url): """Upload trace files to Kayba. PATHS can be files, directories, or '-' for stdin. Directories are walked recursively. """ client = _client(api_key, base_url) trace_list = _collect_upload_traces(paths, file_type) if not trace_list: raise click.ClickException("No traces to upload.") _warn_large_trace_batch(trace_list) try: result = client.upload_traces(trace_list) except KaybaAPIError as exc: raise click.ClickException(str(exc)) count = result.get("count", len(result.get("traces", []))) click.echo(f"Uploaded {count} trace(s).") for t in result.get("traces", []): click.echo(f" {t['id']} {t['filename']}") # --------------------------------------------------------------------------- # run # --------------------------------------------------------------------------- @click.command() @click.option("--traces", "trace_ids", multiple=True, help="Trace IDs to analyze.") @click.option("--all", "select_all", is_flag=True, help="Select all traces.") @click.option( "--model", type=click.Choice(["claude-sonnet-4-6", "claude-opus-4-6"]), default=None, help="Model to use for analysis.", ) @click.option("--epochs", type=int, default=None, help="Analysis epochs (default 1).") @click.option( "--reflector-mode", type=click.Choice(["recursive", "standard"]), default=None, help="Reflector mode.", ) @click.option( "--anthropic-key", envvar="ANTHROPIC_API_KEY", default=None, help="Anthropic API key (or set ANTHROPIC_API_KEY).", ) @click.option("--wait", is_flag=True, help="Poll until the job completes.") @click.option("--json", "as_json", is_flag=True, help="Output raw JSON.") @_api_key_option @_base_url_option def run( trace_ids, select_all, model, epochs, reflector_mode, anthropic_key, wait, as_json, api_key, base_url, ): """Run the analysis pipeline on selected traces. Interactive mode: shows a visual trace selector. Programmatic mode: use --traces ID or --all. """ client = _client(api_key, base_url) # Fetch available traces try: result = client.list_traces() except KaybaAPIError as exc: raise click.ClickException(str(exc)) available = result.get("traces", []) if not available: raise click.ClickException(_no_traces_message()) if select_all: selected_ids = [t["id"] for t in available] elif trace_ids: selected_ids = list(trace_ids) elif sys.stdin.isatty() and not as_json: # Interactive mode: visual checkbox selector selected_ids = _interactive_trace_select(available) if not selected_ids: click.echo("No traces selected.") return else: raise click.ClickException( "Provide --traces ID, --all, or run interactively (TTY)." ) # Start the pipeline try: result = client.generate_insights( trace_ids=selected_ids, model=model, epochs=epochs, reflector_mode=reflector_mode, anthropic_key=anthropic_key, ) except KaybaAPIError as exc: raise click.ClickException(str(exc)) job_id = result["jobId"] if as_json: click.echo(json.dumps({"jobId": job_id, "traces": len(selected_ids)})) else: click.echo(f"Job started: {job_id} ({len(selected_ids)} traces)") if wait: _poll_job(client, job_id) # --------------------------------------------------------------------------- # insights # --------------------------------------------------------------------------- @click.group() def insights(): """Generate, list, and triage insights.""" pass @insights.command("generate") @click.option("--traces", "trace_ids", multiple=True, help="Trace IDs to analyze.") @click.option( "--model", type=click.Choice(["claude-sonnet-4-6", "claude-opus-4-6"]), default=None, help="Model to use for analysis.", ) @click.option("--epochs", type=int, default=None, help="Analysis epochs (default 1).") @click.option( "--reflector-mode", type=click.Choice(["recursive", "standard"]), default=None, help="Reflector mode.", ) @click.option( "--anthropic-key", envvar="ANTHROPIC_API_KEY", default=None, help="Anthropic API key (or set ANTHROPIC_API_KEY).", ) @click.option("--wait", is_flag=True, help="Poll until the job completes.") @_api_key_option @_base_url_option def insights_generate( trace_ids, model, epochs, reflector_mode, anthropic_key, wait, api_key, base_url ): """Trigger insight generation from uploaded traces.""" client = _client(api_key, base_url) try: result = client.generate_insights( trace_ids=list(trace_ids) or None, model=model, epochs=epochs, reflector_mode=reflector_mode, anthropic_key=anthropic_key, ) except KaybaAPIError as exc: raise click.ClickException(str(exc)) job_id = result["jobId"] click.echo(f"Job started: {job_id}") if wait: _poll_job(client, job_id) @insights.command("list") @click.option( "--status", type=click.Choice(["pending", "new", "accepted", "rejected"]), default=None, help="Filter by review status.", ) @click.option("--section", default=None, help="Filter by skillbook section.") @click.option("--json", "as_json", is_flag=True, help="Output raw JSON.") @_api_key_option @_base_url_option def insights_list(status, section, as_json, api_key, base_url): """List insights.""" client = _client(api_key, base_url) try: result = client.list_insights(status=status, section=section) except KaybaAPIError as exc: raise click.ClickException(str(exc)) items = result.get("insights", []) if as_json: click.echo(json.dumps(items, indent=2)) return if not items: click.echo("No insights found.") return for ins in items: status_str = ins.get("status", "?") click.echo(f" [{status_str:>8}] {ins['id']} {ins.get('section', '')}") click.echo(f" {ins.get('content', '')[:120]}") @insights.command("triage") @click.option("--accept", "accept_ids", multiple=True, help="Insight IDs to accept.") @click.option("--reject", "reject_ids", multiple=True, help="Insight IDs to reject.") @click.option("--accept-all", is_flag=True, help="Accept all pending insights.") @click.option("--note", default=None, help="Optional triage note.") @_api_key_option @_base_url_option def insights_triage(accept_ids, reject_ids, accept_all, note, api_key, base_url): """Accept or reject insights.""" client = _client(api_key, base_url) if accept_all: try: result = client.list_insights(status="pending") except KaybaAPIError as exc: raise click.ClickException(str(exc)) accept_ids = tuple(ins["id"] for ins in result.get("insights", [])) if not accept_ids: click.echo("No pending insights to accept.") return if not accept_ids and not reject_ids: raise click.ClickException("Provide --accept, --reject, or --accept-all.") errors = [] for iid in accept_ids: try: client.triage_insight(iid, "accepted", note=note) click.echo(f" Accepted {iid}") except KaybaAPIError as exc: errors.append(str(exc)) click.echo(f" Error accepting {iid}: {exc}", err=True) for iid in reject_ids: try: client.triage_insight(iid, "rejected", note=note) click.echo(f" Rejected {iid}") except KaybaAPIError as exc: errors.append(str(exc)) click.echo(f" Error rejecting {iid}: {exc}", err=True) if errors: raise click.ClickException(f"{len(errors)} triage operation(s) failed.") # --------------------------------------------------------------------------- # prompts # --------------------------------------------------------------------------- @click.group() def prompts(): """Generate, list, and pull prompts.""" pass @prompts.command("generate") @click.option( "--insights", "insight_ids", multiple=True, help="Insight IDs to include." ) @click.option("--label", default=None, help="Label for the generated prompt.") @click.option("-o", "--output", "output_path", default=None, help="Save to file.") @_api_key_option @_base_url_option def prompts_generate(insight_ids, label, output_path, api_key, base_url): """Generate a prompt from accepted insights.""" client = _client(api_key, base_url) try: result = client.generate_prompt( insight_ids=list(insight_ids) or None, label=label, ) except KaybaAPIError as exc: raise click.ClickException(str(exc)) prompt_id = result.get("promptId", "?") version = result.get("version", "?") text = result.get("content", {}).get("text", "") click.echo(f"Prompt {prompt_id} (v{version}) generated.") if output_path: Path(output_path).write_text(text, encoding="utf-8") click.echo(f"Saved to {output_path}") else: click.echo(text) @prompts.command("list") @_api_key_option @_base_url_option def prompts_list(api_key, base_url): """List prompt versions.""" client = _client(api_key, base_url) try: result = client.list_prompts() except KaybaAPIError as exc: raise click.ClickException(str(exc)) items = result if isinstance(result, list) else result.get("prompts", []) if not items: click.echo("No prompts found.") return for p in items: pid = p.get("id", p.get("promptId", "?")) label = p.get("label", "") click.echo(f" {pid} {label}") @prompts.command("pull") @click.option("--id", "prompt_id", default=None, help="Prompt ID (default: latest).") @click.option("-o", "--output", "output_path", default=None, help="Save to file.") @click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") @_api_key_option @_base_url_option def prompts_pull(prompt_id, output_path, pretty, api_key, base_url): """Download a prompt.""" client = _client(api_key, base_url) if prompt_id: try: result = client.get_prompt(prompt_id) except KaybaAPIError as exc: raise click.ClickException(str(exc)) else: # Get latest by listing and picking first try: listing = client.list_prompts() except KaybaAPIError as exc: raise click.ClickException(str(exc)) items = listing if isinstance(listing, list) else listing.get("prompts", []) if not items: raise click.ClickException("No prompts available.") first = items[0] pid = first.get("id", first.get("promptId")) try: result = client.get_prompt(pid) except KaybaAPIError as exc: raise click.ClickException(str(exc)) text = result.get("content", {}).get("text", "") if pretty: output = json.dumps(result, indent=2) else: output = text if output_path: Path(output_path).write_text(output, encoding="utf-8") click.echo(f"Saved to {output_path}") else: click.echo(output) @prompts.command("install") @click.option("--id", "prompt_id", default=None, help="Prompt ID (default: latest).") @click.option( "-i", "--input", "input_path", type=click.Path(exists=True, dir_okay=False), default=None, help="Read prompt text from a local file instead of the API.", ) @click.option( "--target", type=click.Choice(sorted(PROMPT_INSTALL_TARGETS)), default="universal", show_default=True, help="Agent to install the prompt for.", ) @click.option( "--file", "target_path", type=click.Path(dir_okay=False), default=None, help="Override the destination file path.", ) @_api_key_option @_base_url_option def prompts_install(input_path, prompt_id, target, target_path, api_key, base_url): """Install a generated prompt into an agent instruction file.""" if input_path and prompt_id: raise click.ClickException("Use either --input or --id, not both.") if input_path: prompt_ref = input_path text = Path(input_path).read_text(encoding="utf-8") else: client = _client(api_key, base_url) prompt_ref, text = _fetch_prompt_text(client, prompt_id) if not text.strip(): raise click.ClickException("Prompt content is empty.") default_filename, target_label = PROMPT_INSTALL_TARGETS[target] destination = Path(target_path) if target_path else Path(default_filename) _upsert_prompt_block(destination, _build_prompt_block(text)) click.echo(f"Installed Kayba prompt ({prompt_ref}) into {destination}") click.echo( f"Target: {target_label}. Start a new agent session so it reloads the file." ) # --------------------------------------------------------------------------- # status # --------------------------------------------------------------------------- @click.command() @click.argument("job_id") @click.option("--wait", is_flag=True, help="Poll until the job completes.") @click.option( "--interval", type=int, default=5, help="Poll interval in seconds (default 5)." ) @_api_key_option @_base_url_option def status(job_id, wait, interval, api_key, base_url): """Check the status of an analysis job.""" client = _client(api_key, base_url) if wait: _poll_job(client, job_id, interval=interval) else: try: job = client.get_job(job_id) except KaybaAPIError as exc: raise click.ClickException(str(exc)) _print_job(job) # --------------------------------------------------------------------------- # materialize # --------------------------------------------------------------------------- @click.command() @click.argument("job_id") @_api_key_option @_base_url_option def materialize(job_id, api_key, base_url): """Materialize completed job results into the skillbook.""" client = _client(api_key, base_url) try: result = client.materialize_job(job_id) except KaybaAPIError as exc: raise click.ClickException(str(exc)) click.echo( f"Materialized {result.get('skillsGenerated', '?')} skill(s) " f"from job {result.get('jobId', job_id)}." ) # --------------------------------------------------------------------------- # batch # --------------------------------------------------------------------------- DEFAULT_BATCH_PROMPT = """\ You are a trace classification system. Analyze the trace metadata below and group them into coherent batches for analysis by a Recursive Reflector. Constraints: {constraints} Instructions: 1. Group traces by semantic similarity (similar tasks, tools, domains). 2. Respect min/max batch size constraints. 3. Every trace must be assigned to exactly one batch. 4. Use descriptive batch names (lowercase-with-hyphens). Output only valid JSON matching this schema: {{"batches": {{"name": {{"description": "...", "trace_files": [...]}}}}, "summary": {{"total_traces": N, "num_batches": N, "batch_sizes": {{"name": N}}}}}} Trace metadata: {traces_json} """ def _extract_trace_metadata(filename: str, content: str, file_type: str) -> dict: """Extract compact metadata from a trace for prompt context.""" meta: dict = { "filename": filename, "type": file_type, "size": len(content), } if file_type == "json": try: data = json.loads(content) if isinstance(data, dict): if "task_id" in data: meta["task_id"] = data["task_id"] if "user_request" in data: meta["user_request"] = str(data["user_request"])[:200] if "tools" in data: meta["tools"] = data["tools"] steps = data.get("steps") or data.get("events") or [] if isinstance(steps, list): meta["step_count"] = len(steps) except (json.JSONDecodeError, TypeError): pass elif file_type == "md": lines = content.split("\n") meta["summary"] = "\n".join(lines[:10]) headings = [ln for ln in lines if ln.startswith("#")] if headings: meta["headings"] = headings[:20] else: lines = content.split("\n") meta["summary"] = "\n".join(lines[:5]) return meta def _build_classification_prompt( traces_metadata: list[dict], constraints: str, custom_prompt: Optional[str] = None, ) -> str: """Build the classification prompt with metadata and constraints.""" traces_json = json.dumps(traces_metadata, indent=2) template = custom_prompt if custom_prompt else DEFAULT_BATCH_PROMPT return template.format(traces_json=traces_json, constraints=constraints) def _validate_batch_plan( plan: dict, all_filenames: list[str], min_size: int, max_size: int, ) -> list[str]: """Validate a batch plan. Returns list of error strings (empty = valid).""" errors: list[str] = [] batches = plan.get("batches") if not isinstance(batches, dict): errors.append("Missing or invalid 'batches' key (expected dict).") return errors assigned: set[str] = set() for name, batch in batches.items(): files = batch.get("trace_files", []) if not isinstance(files, list): errors.append(f"Batch '{name}': trace_files must be a list.") continue if len(files) < min_size: errors.append(f"Batch '{name}' has {len(files)} traces (min {min_size}).") if len(files) > max_size: errors.append(f"Batch '{name}' has {len(files)} traces (max {max_size}).") for f in files: if f in assigned: errors.append(f"Trace '{f}' assigned to multiple batches.") assigned.add(f) missing = set(all_filenames) - assigned if missing: errors.append(f"Traces not assigned: {sorted(missing)}") extra = assigned - set(all_filenames) if extra: errors.append(f"Unknown traces in plan: {sorted(extra)}") return errors def _upload_batches( plan: dict, traces_by_name: dict[str, dict[str, str]], client: KaybaClient, ) -> None: """Upload each batch to the Kayba API.""" batches = plan.get("batches", {}) for name, batch in batches.items(): files = batch.get("trace_files", []) batch_traces = [traces_by_name[f] for f in files if f in traces_by_name] if not batch_traces: click.echo(f" Skipping empty batch '{name}'.", err=True) continue try: _warn_large_trace_batch(batch_traces) result = client.upload_traces(batch_traces) count = result.get("count", len(batch_traces)) click.echo(f" Uploaded batch '{name}': {count} trace(s).") except KaybaAPIError as exc: click.echo(f" Error uploading batch '{name}': {exc}", err=True) @click.command() @click.argument("paths", nargs=-1) @click.option( "--prompt", "prompt_file", type=click.Path(exists=True), default=None, help="Custom classification prompt file (should contain {traces_json} and {constraints}).", ) @click.option( "-o", "--output", "output_file", default="batches.json", show_default=True, help="Output batch plan file.", ) @click.option( "--apply", "apply_file", type=click.Path(exists=True), default=None, help="Apply an existing batch plan (skip prompt generation).", ) @click.option( "--upload", "do_upload", is_flag=True, help="Upload each batch to the API (requires --apply).", ) @click.option("--max-batch-size", type=int, default=30, show_default=True) @click.option("--min-batch-size", type=int, default=10, show_default=True) @_api_key_option @_base_url_option def batch( paths, prompt_file, output_file, apply_file, do_upload, max_batch_size, min_batch_size, api_key, base_url, ): """Pre-batch traces for the Recursive Reflector. Two modes: Prepare (default): collect traces, extract metadata, print a classification prompt to stdout for Claude Code to process. Apply (--apply FILE): validate a batch plan JSON and optionally upload. PATHS can be files or directories (walked recursively). """ if not paths: raise click.ClickException("Provide at least one path.") # ---- Collect traces ---- traces: list[dict[str, str]] = [] for item in paths: p = Path(item) if p.is_dir(): for child in sorted(p.rglob("*")): if child.is_file(): _add_file(traces, child, None) elif p.is_file(): _add_file(traces, p, None) else: click.echo(f"Warning: skipping {item} (not found)", err=True) if not traces: raise click.ClickException("No trace files found.") all_filenames = [t["filename"] for t in traces] # ---- Mode 2: Apply ---- if apply_file: plan_text = Path(apply_file).read_text(encoding="utf-8") try: plan = json.loads(plan_text) except json.JSONDecodeError as exc: raise click.ClickException(f"Invalid JSON in {apply_file}: {exc}") errors = _validate_batch_plan( plan, all_filenames, min_batch_size, max_batch_size ) if errors: for err in errors: click.echo(f" Error: {err}", err=True) raise click.ClickException("Batch plan validation failed.") num_batches = len(plan.get("batches", {})) click.echo( f"Batch plan valid: {num_batches} batch(es), {len(traces)} trace(s)." ) if do_upload: client = _client(api_key, base_url) traces_by_name = {t["filename"]: t for t in traces} _upload_batches(plan, traces_by_name, client) click.echo("Upload complete.") return # ---- Mode 1: Prepare ---- if do_upload: raise click.ClickException("--upload requires --apply.") metadata = [ _extract_trace_metadata(t["filename"], t["content"], t["fileType"]) for t in traces ] constraints = f"min_batch_size={min_batch_size}, max_batch_size={max_batch_size}" custom_prompt = None if prompt_file: custom_prompt = Path(prompt_file).read_text(encoding="utf-8") prompt_text = _build_classification_prompt(metadata, constraints, custom_prompt) # Write metadata to output file as starting point starter = { "batches": {}, "summary": {"total_traces": len(traces), "num_batches": 0, "batch_sizes": {}}, } out_path = Path(output_file) out_path.write_text(json.dumps(starter, indent=2), encoding="utf-8") click.echo(f"Wrote metadata to {out_path}", err=True) click.echo(f"Found {len(traces)} trace(s).", err=True) # Print prompt to stdout for Claude Code click.echo(prompt_text) # --------------------------------------------------------------------------- # integrations # --------------------------------------------------------------------------- def _mask_token(token: str) -> str: """Mask a token/key for display, showing first 4 + last 4 chars.""" if not token or len(token) <= 8: return "****" return f"{token[:4]}...{token[-4:]}" @click.group() def integrations(): """Manage platform integrations (MLflow, LangSmith).""" pass @integrations.command("list") @click.option("--json", "as_json", is_flag=True, help="Output raw JSON.") @_api_key_option @_base_url_option def integrations_list(as_json, api_key, base_url): """Show configured integrations.""" client = _client(api_key, base_url) try: result = client.get_integrations() except KaybaAPIError as exc: raise click.ClickException(str(exc)) if as_json: click.echo(json.dumps(result, indent=2)) return for name in ("mlflow", "langsmith"): config = result.get(name, {}) enabled = config.get("enabled", False) status_str = "enabled" if enabled else "disabled" click.echo(f"\n {name}") click.echo(f" Status: {status_str}") if name == "mlflow": uri = config.get("trackingUri", "") auth = config.get("authType", "none") experiment = config.get("experimentName", "") if uri: click.echo(f" Tracking URI: {uri}") click.echo(f" Auth type: {auth}") if config.get("token"): click.echo(f" Token: {_mask_token(config['token'])}") if config.get("username"): click.echo(f" Username: {config['username']}") if experiment: click.echo(f" Experiment: {experiment}") elif name == "langsmith": api_url = config.get("apiUrl", "") project = config.get("projectName", "") if api_url: click.echo(f" API URL: {api_url}") if config.get("apiKey"): click.echo(f" API key: {_mask_token(config['apiKey'])}") if project: click.echo(f" Project: {project}") click.echo() @integrations.command("configure") @click.argument("name", type=click.Choice(["mlflow", "langsmith"])) @_api_key_option @_base_url_option def integrations_configure(name, api_key, base_url): """Interactively configure an integration.""" client = _client(api_key, base_url) # Fetch current config for defaults try: current = client.get_integrations() except KaybaAPIError: current = {} existing = current.get(name, {}) if name == "mlflow": tracking_uri = click.prompt( "MLflow tracking URI", default=existing.get("trackingUri", ""), ) auth_type = click.prompt( "Auth type", type=click.Choice(["none", "basic", "bearer", "databricks"]), default=existing.get("authType", "none"), ) token = "" username = "" if auth_type in ("basic", "bearer", "databricks"): token = click.prompt( "Token / password", default="", hide_input=True, show_default=False, ) if auth_type == "basic": username = click.prompt( "Username", default=existing.get("username", ""), ) experiment_name = click.prompt( "Experiment name (optional filter)", default=existing.get("experimentName", ""), ) config = { "enabled": True, "trackingUri": tracking_uri, "authType": auth_type, "token": token, "username": username, "experimentName": experiment_name, } elif name == "langsmith": api_url = click.prompt( "LangSmith API URL", default=existing.get("apiUrl", "https://api.smith.langchain.com"), ) langsmith_key = click.prompt( "LangSmith API key", default="", hide_input=True, show_default=False, ) project_name = click.prompt( "Project name (optional filter)", default=existing.get("projectName", ""), ) config = { "enabled": True, "apiUrl": api_url, "apiKey": langsmith_key, "projectName": project_name, } try: client.update_integration(name, config) click.echo(f"\n {name} configuration saved.") except KaybaAPIError as exc: raise click.ClickException(str(exc)) # Auto-test the connection click.echo(f" Testing {name} connection...") try: test_result = client.test_integration(name) if test_result.get("connected"): click.echo(f" Connected successfully.") if name == "mlflow" and test_result.get("mlflowVersion"): click.echo(f" MLflow version: {test_result['mlflowVersion']}") else: click.echo(f" Warning: connection test returned unexpected result.") except KaybaAPIError as exc: click.echo(f" Warning: connection test failed: {exc}", err=True) @integrations.command("test") @click.argument("name", type=click.Choice(["mlflow", "langsmith"])) @_api_key_option @_base_url_option def integrations_test(name, api_key, base_url): """Test an integration connection.""" client = _client(api_key, base_url) try: result = client.test_integration(name) except KaybaAPIError as exc: raise click.ClickException(str(exc)) if result.get("connected"): click.echo(f" {name}: connected") if name == "mlflow" and result.get("mlflowVersion"): click.echo(f" MLflow version: {result['mlflowVersion']}") if name == "mlflow" and result.get("experimentCount") is not None: click.echo(f" Experiments found: {result['experimentCount']}") else: error = result.get("error", "Unknown error") raise click.ClickException(f"{name}: connection failed — {error}") # --------------------------------------------------------------------------- # setup # --------------------------------------------------------------------------- @click.command() @click.option( "--append-to", type=click.Path(), default=None, help="Append instructions to this file (default: print to stdout). " "Recommended: AGENTS.md (universal), CLAUDE.md, .cursorrules.", ) @click.option( "--skills/--no-skills", default=True, help="Install Claude Code skills (default: enabled).", ) @click.option( "--project-dir", type=click.Path(exists=True, file_okay=False), default=".", help="Project root directory (default: current directory).", ) def setup(append_to, skills, project_dir): """Print or install Kayba CLI instructions and skills for coding agents.""" snippet = ( importlib.resources.files("ace.cli.commands") .joinpath("kayba-agent-instructions.md") .read_text(encoding="utf-8") ) if append_to: path = Path(append_to) mode = "a" if path.exists() else "w" with path.open(mode, encoding="utf-8") as f: if mode == "a": f.write("\n\n") f.write(snippet) click.echo(f"Appended Kayba CLI instructions to {path}") else: click.echo(snippet) if skills: target = Path(project_dir) / ".claude" / "skills" _install_skills(target) def _install_skills(target_dir: Path) -> None: """Copy bundled skill files to the target .claude/skills/ directory.""" skills_pkg = importlib.resources.files("ace.cli.skills") installed = [] for skill_dir in skills_pkg.iterdir(): if skill_dir.name.startswith("_") or not skill_dir.is_dir(): continue dest = target_dir / skill_dir.name dest.mkdir(parents=True, exist_ok=True) # Copy top-level SKILL.md skill_file = skill_dir / "SKILL.md" if skill_file.is_file(): (dest / "SKILL.md").write_bytes(skill_file.read_bytes()) # Copy stage subdirectories for sub in skill_dir.iterdir(): if sub.is_dir() and not sub.name.startswith("_"): sub_dest = dest / sub.name sub_dest.mkdir(parents=True, exist_ok=True) sub_skill = sub / "SKILL.md" if sub_skill.is_file(): (sub_dest / "SKILL.md").write_bytes(sub_skill.read_bytes()) stages = [d.name for d in dest.iterdir() if d.is_dir()] installed.append((skill_dir.name, len(stages))) if installed: click.echo(f"\nInstalled skills to {target_dir}/:") for name, stage_count in installed: click.echo(f" {name} ({stage_count} stages)") else: click.echo("\nNo skills found to install.") # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _interactive_trace_select(traces: list[dict]) -> list[str]: """Visual checkbox selector for traces.""" try: import questionary except ImportError: raise click.ClickException( "Interactive mode requires 'questionary'. " "Install with: pip install 'ace-framework[cloud]'" ) choices = [] for t in traces: size = _format_size(t.get("size", 0)) age = _format_age(t.get("uploadedAt", "")) label = f"{t['filename']:<40} {size:>8} {age}" choices.append(questionary.Choice(title=label, value=t["id"])) selected = questionary.checkbox( "Select traces (space to toggle, a to toggle all, enter to confirm):", choices=choices, ).ask() if selected is None: # Ctrl-C return [] return selected def _no_traces_message() -> str: """Explain why a new hosted account often has no traces yet.""" return ( "No traces found in your Kayba account.\n" "Kayba does not auto-import local agent transcripts yet.\n" "If you're using Claude Code, upload its local .jsonl files first, for example:\n" " kayba traces upload ~/.claude/projects//*.jsonl\n" "Docs: https://kayba.ai/docs/integrations/hosted-api/#where-do-traces-come-from" ) def _fetch_prompt_text(client: KaybaClient, prompt_id: Optional[str]) -> tuple[str, str]: """Load a prompt body from the API.""" if prompt_id: result = client.get_prompt(prompt_id) prompt_ref = prompt_id else: listing = client.list_prompts() items = listing if isinstance(listing, list) else listing.get("prompts", []) if not items: raise click.ClickException( "No prompts available. Generate one first with `kayba prompts generate`." ) first = items[0] prompt_ref = str(first.get("id", first.get("promptId", "latest"))) result = client.get_prompt(prompt_ref) text = result.get("content", {}).get("text", "") if not text.strip(): raise click.ClickException(f"Prompt {prompt_ref} is empty.") return prompt_ref, text def _build_prompt_block(prompt_text: str) -> str: """Wrap prompt text in a managed block so repeated installs replace cleanly.""" body = prompt_text.strip() return ( f"{PROMPT_BLOCK_START}\n" "## Kayba Prompt\n" "_Managed by `kayba prompts install`. Re-run the command to update this block._\n\n" f"{body}\n" f"{PROMPT_BLOCK_END}\n" ) def _upsert_prompt_block(path: Path, block: str) -> None: """Replace an existing managed prompt block or append a new one.""" existing = path.read_text(encoding="utf-8") if path.exists() else "" pattern = re.compile( rf"{re.escape(PROMPT_BLOCK_START)}.*?{re.escape(PROMPT_BLOCK_END)}\n?", re.DOTALL, ) if pattern.search(existing): updated = pattern.sub(block, existing, count=1) elif existing.strip(): updated = existing.rstrip() + "\n\n" + block else: updated = block path.parent.mkdir(parents=True, exist_ok=True) path.write_text(updated, encoding="utf-8") def _format_size(size_bytes: int) -> str: """Format bytes as human-readable.""" if size_bytes < 1024: return f"{size_bytes} B" elif size_bytes < 1024 * 1024: return f"{size_bytes / 1024:.1f} KB" else: return f"{size_bytes / (1024 * 1024):.1f} MB" def _format_age(iso_str: str) -> str: """Format ISO datetime as relative time.""" if not iso_str: return "" try: dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) now = datetime.now(timezone.utc) diff = now - dt seconds = diff.total_seconds() if seconds < 60: return "just now" elif seconds < 3600: return f"{int(seconds / 60)}m ago" elif seconds < 86400: return f"{int(seconds / 3600)}h ago" else: return f"{int(seconds / 86400)}d ago" except (ValueError, TypeError): return iso_str def _poll_job(client: KaybaClient, job_id: str, *, interval: int = 5): """Poll a job until it reaches a terminal state.""" terminal = {"completed", "failed"} while True: try: job = client.get_job(job_id) except KaybaAPIError as exc: raise click.ClickException(str(exc)) st = job.get("status", "unknown") click.echo(f" {job_id} {st}") if st in terminal: _print_job(job) if st == "completed": click.echo(f"\nRun: kayba materialize {job_id}") return time.sleep(interval) def _print_job(job: dict): """Pretty-print a job status dict.""" click.echo(f"Job: {job.get('jobId', '?')}") click.echo(f"Status: {job.get('status', '?')}") if job.get("startedAt"): click.echo(f"Started: {job['startedAt']}") if job.get("completedAt"): click.echo(f"Completed: {job['completedAt']}") if job.get("error"): click.echo(f"Error: {job['error']}") result = job.get("result") if result: click.echo(f"Skills generated: {result.get('skillsGenerated', '?')}") if result.get("summary"): click.echo(f"Summary: {result['summary']}") click.echo(f"Materialized: {result.get('materialized', False)}")