Spaces:
Sleeping
Sleeping
| """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 = "<!-- KAYBA:PROMPT:START -->" | |
| PROMPT_BLOCK_END = "<!-- KAYBA:PROMPT: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 | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| def traces(): | |
| """List, view, upload, and delete traces.""" | |
| pass | |
| 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)") | |
| 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) | |
| 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.") | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| def insights(): | |
| """Generate, list, and triage insights.""" | |
| pass | |
| 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) | |
| 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]}") | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| def prompts(): | |
| """Generate, list, and pull prompts.""" | |
| pass | |
| 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) | |
| 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}") | |
| 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) | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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) | |
| 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:]}" | |
| def integrations(): | |
| """Manage platform integrations (MLflow, LangSmith).""" | |
| pass | |
| 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() | |
| 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) | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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/<project>/*.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)}") | |