import argparse import os import re import sys from pathlib import Path import huggingface_hub import trackio from trackio import freeze, show, sync from trackio.cli_helpers import ( error_exit, format_alerts, format_artifact, format_artifacts, format_json, format_list, format_metric_values, format_project_summary, format_query_result, format_run_summary, format_snapshot, format_spaces, format_system_metric_names, format_system_metrics, ) from trackio.frontend_config import ( TRACKIO_CONFIG_PATH, get_persisted_frontend_dir, set_persisted_frontend_dir, unset_persisted_frontend_dir, ) from trackio.markdown import Markdown from trackio.server import get_project_summary, get_run_summary from trackio.sqlite_storage import SQLiteStorage def _get_space(args): return getattr(args, "space", None) def _get_remote(args): from trackio.remote_client import RemoteClient space = _get_space(args) if not space: return None hf_token = getattr(args, "hf_token", None) return RemoteClient(space, hf_token=hf_token) def _handle_status(): print("Reading local Trackio projects...\n") projects = SQLiteStorage.get_projects() if not projects: print("No Trackio projects found.") return local_projects = [] synced_projects = [] unsynced_projects = [] for project in projects: space_id = SQLiteStorage.get_space_id(project) if space_id is None: local_projects.append(project) elif SQLiteStorage.has_pending_data(project): unsynced_projects.append(project) else: synced_projects.append(project) print("Finished reading Trackio projects") if local_projects: print(f" * {len(local_projects)} local trackio project(s) [OK]") if synced_projects: print(f" * {len(synced_projects)} trackio project(s) synced to Spaces [OK]") if unsynced_projects: print( f" * {len(unsynced_projects)} trackio project(s) with unsynced changes [WARNING]:" ) for p in unsynced_projects: print(f" - {p}") if unsynced_projects: print( f"\nRun `trackio sync --project {unsynced_projects[0]}` to sync. " "Or run `trackio sync --all` to sync all unsynced changes." ) def _handle_sync(args): from trackio.deploy import sync_incremental if args.sync_all and args.project: error_exit("Cannot use --all and --project together.") if not args.sync_all and not args.project: error_exit("Must provide either --project or --all.") if args.sync_all: projects = SQLiteStorage.get_projects() synced_any = False for project in projects: space_id = SQLiteStorage.get_space_id(project) if space_id and SQLiteStorage.has_pending_data(project): sync_incremental( project, space_id, private=args.private, pending_only=True, frontend_dir=args.frontend, ) synced_any = True if not synced_any: print("No projects with unsynced data found.") else: space_id = args.space_id if space_id is None: space_id = SQLiteStorage.get_space_id(args.project) sync( project=args.project, space_id=space_id, private=args.private, force=args.force, sdk=args.sdk, frontend_dir=args.frontend, ) def _handle_config(args): if args.config_command == "get": frontend_dir = get_persisted_frontend_dir() if frontend_dir is None: print("No Trackio frontend config is set.") print(f"Config file: {TRACKIO_CONFIG_PATH}") return print(f"frontend: {frontend_dir}") print(f"config: {TRACKIO_CONFIG_PATH}") return if args.config_command == "set": try: frontend_dir = set_persisted_frontend_dir(args.frontend) except ValueError as e: error_exit(str(e)) print(f"Saved Trackio default frontend: {frontend_dir}") print("Reset with `trackio config unset frontend`.") return if args.config_command == "unset": removed = unset_persisted_frontend_dir() if removed: print("Removed Trackio default frontend.") else: print("No Trackio default frontend was set.") return def _extract_reports( run: str, logs: list[dict], report_name: str | None = None ) -> list[dict]: reports = [] for log in logs: timestamp = log.get("timestamp") step = log.get("step") for key, value in log.items(): if report_name is not None and key != report_name: continue if isinstance(value, dict) and value.get("_type") == Markdown.TYPE: content = value.get("_value") if isinstance(content, str): reports.append( { "run": run, "report": key, "step": step, "timestamp": timestamp, "content": content, } ) return reports def _handle_query(args): remote = _get_remote(args) try: if remote: result = remote.predict(args.project, args.sql, api_name="/query_project") else: result = SQLiteStorage.query_project(args.project, args.sql) except FileNotFoundError as e: error_exit(str(e)) except ValueError as e: error_exit(str(e)) if args.json: print(format_json(result)) else: print(format_query_result(result)) def _datetime_to_iso(value): return value.isoformat() if value is not None else None def _space_url(space) -> str | None: host = getattr(space, "host", None) if host: return host if host.startswith("http") else f"https://{host}" subdomain = getattr(space, "subdomain", None) if subdomain: return f"https://{subdomain}.hf.space" space_id = getattr(space, "id", None) if not space_id: return None return f"https://huggingface.co/spaces/{space_id}" def _serialize_space(space) -> dict: space_id = getattr(space, "id", "") namespace, _, name = space_id.partition("/") return { "id": space_id, "namespace": namespace, "name": name or space_id, "author": getattr(space, "author", None) or namespace, "private": bool(getattr(space, "private", False)), "sdk": getattr(space, "sdk", None), "url": _space_url(space), "last_modified": _datetime_to_iso( getattr(space, "last_modified", None) or getattr(space, "lastModified", None) ), "created_at": _datetime_to_iso(getattr(space, "created_at", None)), "tags": getattr(space, "tags", None) or [], } def _maybe_handle_logbook_run_argv() -> bool: argv = sys.argv[1:] try: logbook_idx = argv.index("logbook") except ValueError: return False if len(argv) <= logbook_idx + 1 or argv[logbook_idx + 1] != "run": return False run_argv = argv[logbook_idx + 2 :] run_parser = argparse.ArgumentParser( prog=f"{os.path.basename(sys.argv[0])} logbook run", description=( "Run a command; log the command, its scripts, and output to a page" ), ) run_parser.add_argument("--page", help="Page title or slug") run_parser.add_argument("--title", help="Cell title") run_parser.add_argument( "--no-artifacts", action="store_true", help="Do not record output model/data files as artifact cells", ) if "--" in run_argv: sep = run_argv.index("--") opts = run_parser.parse_args(run_argv[:sep]) command = run_argv[sep + 1 :] else: opts, command = run_parser.parse_known_args(run_argv) if not command: run_parser.error("No command provided. Use: trackio logbook run -- ") args = argparse.Namespace( logbook_action="run", page=opts.page, title=opts.title, no_artifacts=opts.no_artifacts, command=command, ) _handle_logbook(args) return True def _maybe_rewrite_logbook_read_argv() -> None: argv = sys.argv for i in range(1, len(argv) - 1): if argv[i] == "logbook" and argv[i + 1] == "read": j = i + 2 if ( j < len(argv) and not argv[j].startswith("-") and argv[j] not in ("pages", "page", "cell") ): argv[j : j + 1] = ["--path", argv[j]] return def _trackio_space_namespaces(api, token: str | None, author: str | None) -> list[str]: if author: return [author] if not token: error_exit( "Log in with `huggingface-cli login`, pass `--hf-token`, or provide `--author`." ) try: whoami = api.whoami(token=token, cache=True) except Exception as e: error_exit(f"Failed to read Hugging Face account information: {e}") namespaces = [whoami["name"]] for org in whoami.get("orgs", []): org_name = org.get("name") if isinstance(org, dict) else org if org_name: namespaces.append(org_name) return list(dict.fromkeys(namespaces)) def _handle_list_spaces(args): if _get_space(args): error_exit("The 'list spaces' command does not support --space.") if args.limit is not None and args.limit < 0: error_exit("--limit must be zero or greater.") token = args.hf_token or huggingface_hub.utils.get_token() api = huggingface_hub.HfApi(token=token) namespaces = _trackio_space_namespaces(api, token, args.author) spaces_by_id = {} try: for namespace in namespaces: spaces = api.list_spaces( author=namespace, filter="trackio", full=True, token=token, ) for space in spaces: space_id = getattr(space, "id", None) if space_id: spaces_by_id[space_id] = _serialize_space(space) except Exception as e: error_exit(f"Failed to list Trackio Spaces: {e}") spaces = sorted( spaces_by_id.values(), key=lambda space: space.get("last_modified") or "", reverse=True, ) if args.limit is not None: spaces = spaces[: args.limit] if args.json: print(format_json({"spaces": spaces})) else: print(format_spaces(spaces)) def main(): if _maybe_handle_logbook_run_argv(): return _maybe_rewrite_logbook_read_argv() parser = argparse.ArgumentParser(description="Trackio CLI") parser.add_argument( "--version", action="version", version=f"trackio {trackio.__version__}", ) parser.add_argument( "--space", required=False, help="HF Space ID (e.g. 'user/space') or Space URL to query remotely.", ) parser.add_argument( "--hf-token", required=False, help="HF token for accessing private Spaces.", ) subparsers = parser.add_subparsers(dest="command") ui_parser = subparsers.add_parser( "show", help="Show the Trackio dashboard UI for a project" ) ui_parser.add_argument( "--project", required=False, help="Project name to show in the dashboard" ) ui_parser.add_argument( "--theme", required=False, default="default", help="A Gradio Theme to use for the dashboard instead of the default, can be a built-in theme (e.g. 'soft', 'citrus'), or a theme from the Hub (e.g. 'gstaff/xkcd').", ) ui_parser.add_argument( "--mcp-server", action="store_true", help="Enable MCP server functionality. The Trackio dashboard will be set up as an MCP server and certain functions will be exposed as MCP tools.", ) ui_parser.add_argument( "--footer", action="store_true", default=True, help="Show the Gradio footer. Use --no-footer to hide it.", ) ui_parser.add_argument( "--no-footer", dest="footer", action="store_false", help="Hide the Gradio footer.", ) ui_parser.add_argument( "--color-palette", required=False, help="Comma-separated list of hex color codes for plot lines (e.g. '#FF0000,#00FF00,#0000FF'). If not provided, the TRACKIO_COLOR_PALETTE environment variable will be used, or the default palette if not set.", ) ui_parser.add_argument( "--host", required=False, help="Host to bind the server to (e.g. '0.0.0.0' for remote access). If not provided, defaults to '127.0.0.1' (localhost only).", ) ui_parser.add_argument( "--frontend", required=False, help="Custom frontend directory to serve. Must contain index.html.", ) subparsers.add_parser( "status", help="Show the status of all local Trackio projects, including sync status.", ) sync_parser = subparsers.add_parser( "sync", help="Sync a local project's database to a Hugging Face Space. If the Space does not exist, it will be created.", ) sync_parser.add_argument( "--project", required=False, help="The name of the local project.", ) sync_parser.add_argument( "--space-id", required=False, help="The Hugging Face Space ID where the project will be synced (e.g. username/space_id). If not provided, uses the previously-configured Space.", ) sync_parser.add_argument( "--all", action="store_true", dest="sync_all", help="Sync all projects that have unsynced data to their configured Spaces.", ) sync_parser.add_argument( "--private", action="store_true", help="Make the Hugging Face Space private if creating a new Space. By default, the repo will be public unless the organization's default is private. This value is ignored if the repo already exists.", ) sync_parser.add_argument( "--force", action="store_true", help="Overwrite the existing database without prompting for confirmation.", ) sync_parser.add_argument( "--sdk", choices=["gradio", "static"], default="gradio", help="The type of Space to deploy. 'gradio' (default) deploys a live Gradio server. 'static' deploys a static Space that reads from an HF Bucket.", ) sync_parser.add_argument( "--frontend", required=False, help="Custom frontend directory to deploy. Must contain index.html.", ) freeze_parser = subparsers.add_parser( "freeze", help="Create a one-time static Space snapshot from a project's data.", ) freeze_parser.add_argument( "--space-id", required=True, help="The source Gradio Space ID (e.g. username/space_id).", ) freeze_parser.add_argument( "--project", required=True, help="The name of the project to freeze into a static snapshot.", ) freeze_parser.add_argument( "--new-space-id", required=False, help="The Space ID for the new static Space. Defaults to {space_id}_static.", ) freeze_parser.add_argument( "--private", action="store_true", help="Make the new static Space private.", ) freeze_parser.add_argument( "--frontend", required=False, help="Custom frontend directory to deploy to the frozen static Space.", ) config_parser = subparsers.add_parser( "config", help="Manage persistent Trackio configuration.", ) config_subparsers = config_parser.add_subparsers( dest="config_command", required=True, ) config_subparsers.add_parser("get", help="Show current Trackio config.") config_set_parser = config_subparsers.add_parser( "set", help="Set a persistent Trackio config value.", ) config_set_parser.add_argument( "key", choices=["frontend"], help="Config key to set.", ) config_set_parser.add_argument( "frontend", help="Frontend directory to persist.", ) config_unset_parser = config_subparsers.add_parser( "unset", help="Unset a persistent Trackio config value.", ) config_unset_parser.add_argument( "key", choices=["frontend"], help="Config key to unset.", ) list_parser = subparsers.add_parser( "list", help="List projects, runs, or metrics", ) list_subparsers = list_parser.add_subparsers(dest="list_type", required=True) list_projects_parser = list_subparsers.add_parser( "projects", help="List all projects", ) list_projects_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) list_spaces_parser = list_subparsers.add_parser( "spaces", help="List Trackio Spaces for your Hugging Face account and organizations", ) list_spaces_parser.add_argument( "--author", required=False, help="Only list Trackio Spaces under this user or organization namespace.", ) list_spaces_parser.add_argument( "--limit", type=int, required=False, help="Maximum number of Spaces to return.", ) list_spaces_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) list_runs_parser = list_subparsers.add_parser( "runs", help="List runs for a project", ) list_runs_parser.add_argument( "--project", required=True, help="Project name", ) list_runs_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) list_metrics_parser = list_subparsers.add_parser( "metrics", help="List metrics for a run", ) list_metrics_parser.add_argument( "--project", required=True, help="Project name", ) list_metrics_parser.add_argument( "--run", required=True, help="Run name", ) list_metrics_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) list_system_metrics_parser = list_subparsers.add_parser( "system-metrics", help="List system metrics for a run", ) list_system_metrics_parser.add_argument( "--project", required=True, help="Project name", ) list_system_metrics_parser.add_argument( "--run", required=True, help="Run name", ) list_system_metrics_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) list_alerts_parser = list_subparsers.add_parser( "alerts", help="List alerts for a project or run", ) list_alerts_parser.add_argument( "--project", required=True, help="Project name", ) list_alerts_parser.add_argument( "--run", required=False, help="Run name (optional)", ) list_alerts_parser.add_argument( "--level", required=False, help="Filter by alert level (info, warn, error)", ) list_alerts_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) list_alerts_parser.add_argument( "--since", required=False, help="Only show alerts after this ISO 8601 timestamp", ) list_reports_parser = list_subparsers.add_parser( "reports", help="List markdown reports for a project or run", ) list_reports_parser.add_argument( "--project", required=True, help="Project name", ) list_reports_parser.add_argument( "--run", required=False, help="Run name (optional)", ) list_reports_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) list_artifacts_parser = list_subparsers.add_parser( "artifacts", help="List artifacts for a project", ) list_artifacts_parser.add_argument( "--project", required=True, help="Project name", ) list_artifacts_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) get_parser = subparsers.add_parser( "get", help="Get project, run, or metric information", ) get_subparsers = get_parser.add_subparsers(dest="get_type", required=True) get_project_parser = get_subparsers.add_parser( "project", help="Get project summary", ) get_project_parser.add_argument( "--project", required=True, help="Project name", ) get_project_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) get_run_parser = get_subparsers.add_parser( "run", help="Get run summary", ) get_run_parser.add_argument( "--project", required=True, help="Project name", ) get_run_parser.add_argument( "--run", required=True, help="Run name", ) get_run_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) get_artifact_parser = get_subparsers.add_parser( "artifact", help="Get an artifact version (manifest, aliases, metadata)", ) get_artifact_parser.add_argument( "--project", required=True, help="Project name", ) get_artifact_parser.add_argument( "--name", required=True, help="Artifact name", ) get_artifact_parser.add_argument( "--version", required=False, help="Version or alias to resolve (e.g. 'v2' or 'best'). Defaults to latest.", ) get_artifact_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) get_metric_parser = get_subparsers.add_parser( "metric", help="Get metric values for a run", ) get_metric_parser.add_argument( "--project", required=True, help="Project name", ) get_metric_parser.add_argument( "--run", required=True, help="Run name", ) get_metric_parser.add_argument( "--metric", required=True, help="Metric name", ) get_metric_parser.add_argument( "--step", type=int, required=False, help="Get metric at exactly this step", ) get_metric_parser.add_argument( "--around", type=int, required=False, help="Get metrics around this step (use with --window)", ) get_metric_parser.add_argument( "--at-time", required=False, help="Get metrics around this ISO 8601 timestamp (use with --window)", ) get_metric_parser.add_argument( "--window", type=int, required=False, default=10, help="Window size: ±steps for --around, ±seconds for --at-time (default: 10)", ) get_metric_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) get_snapshot_parser = get_subparsers.add_parser( "snapshot", help="Get all metrics at/around a step or timestamp", ) get_snapshot_parser.add_argument( "--project", required=True, help="Project name", ) get_snapshot_parser.add_argument( "--run", required=True, help="Run name", ) get_snapshot_parser.add_argument( "--step", type=int, required=False, help="Get all metrics at exactly this step", ) get_snapshot_parser.add_argument( "--around", type=int, required=False, help="Get all metrics around this step (use with --window)", ) get_snapshot_parser.add_argument( "--at-time", required=False, help="Get all metrics around this ISO 8601 timestamp (use with --window)", ) get_snapshot_parser.add_argument( "--window", type=int, required=False, default=10, help="Window size: ±steps for --around, ±seconds for --at-time (default: 10)", ) get_snapshot_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) get_system_metric_parser = get_subparsers.add_parser( "system-metric", help="Get system metric values for a run", ) get_system_metric_parser.add_argument( "--project", required=True, help="Project name", ) get_system_metric_parser.add_argument( "--run", required=True, help="Run name", ) get_system_metric_parser.add_argument( "--metric", required=False, help="System metric name (optional, if not provided returns all system metrics)", ) get_system_metric_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) get_alerts_parser = get_subparsers.add_parser( "alerts", help="Get alerts for a project or run", ) get_alerts_parser.add_argument( "--project", required=True, help="Project name", ) get_alerts_parser.add_argument( "--run", required=False, help="Run name (optional)", ) get_alerts_parser.add_argument( "--level", required=False, help="Filter by alert level (info, warn, error)", ) get_alerts_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) get_alerts_parser.add_argument( "--since", required=False, help="Only show alerts after this ISO 8601 timestamp", ) get_report_parser = get_subparsers.add_parser( "report", help="Get markdown report entries for a run", ) get_report_parser.add_argument( "--project", required=True, help="Project name", ) get_report_parser.add_argument( "--run", required=True, help="Run name", ) get_report_parser.add_argument( "--report", required=True, help="Report metric name", ) get_report_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) query_parser = subparsers.add_parser( "query", help="Run a read-only SQL query against a project database", ) query_subparsers = query_parser.add_subparsers(dest="query_type", required=True) query_project_parser = query_subparsers.add_parser( "project", help="Run a read-only SQL query against a project's SQLite database", ) query_project_parser.add_argument( "--project", required=True, help="Project name", ) query_project_parser.add_argument( "--sql", required=True, help="Read-only SQL query to execute", ) query_project_parser.add_argument( "--json", action="store_true", help="Output in JSON format", ) skills_parser = subparsers.add_parser( "skills", help="Manage Trackio skills for AI coding assistants", ) skills_subparsers = skills_parser.add_subparsers( dest="skills_action", required=True ) skills_add_parser = skills_subparsers.add_parser( "add", help=( "Install the Trackio skill to the central .agents/skills location; " "pass agent flags to also symlink it for specific assistants" ), ) skills_add_parser.add_argument( "--cursor", action="store_true", help="Install for Cursor", ) skills_add_parser.add_argument( "--claude", action="store_true", help="Install for Claude Code", ) skills_add_parser.add_argument( "--codex", action="store_true", help="Install for Codex", ) skills_add_parser.add_argument( "--opencode", action="store_true", help="Install for OpenCode", ) skills_add_parser.add_argument( "--pi", action="store_true", help="Install for pi", ) skills_add_parser.add_argument( "--global", dest="global_", action="store_true", help="Install globally (user-level) instead of in the current project directory", ) skills_add_parser.add_argument( "--dest", type=str, required=False, help="Install into a custom destination (path to skills directory)", ) skills_add_parser.add_argument( "--force", action="store_true", help="Overwrite existing skill if it already exists", ) skills_add_parser.add_argument( "--no-command", dest="no_command", action="store_true", help="Do not install the /logbook slash command alongside the skill", ) skills_add_parser.add_argument( "--no-hook", dest="no_hook", action="store_true", help="Do not install the Claude Code hook that mirrors todos into the logbook", ) logbook_parser = subparsers.add_parser( "logbook", help="Create and publish a shareable experiment logbook", ) logbook_sub = logbook_parser.add_subparsers( dest="logbook_action", required=True, metavar="{open,cell,run,page,read,serve,publish,sync}", ) lb_open = logbook_sub.add_parser( "open", help="Start or attach to the logbook in this directory" ) lb_open.add_argument( "space_id", nargs="?", help="Optional HF Space id to publish to" ) lb_open.add_argument("--title", help="Logbook title (only when creating)") lb_open.add_argument( "--no-serve", action="store_true", help="Open or attach without launching the local logbook preview", ) lb_open.add_argument("--port", type=int, default=7861) lb_open.add_argument("--no-browser", action="store_true") lb_cell = logbook_sub.add_parser( "cell", help="Append a typed notebook-style cell to a logbook page" ) lb_cell_sub = lb_cell.add_subparsers(dest="cell_type", required=True) lb_cell_md = lb_cell_sub.add_parser("markdown", help="Append a markdown cell") lb_cell_md.add_argument("body", help="Markdown body") lb_cell_md.add_argument("--title", help="Cell title") lb_cell_md.add_argument("--page", help="Page title or slug") lb_cell_art = lb_cell_sub.add_parser( "artifact", help="Append an artifact cell referencing a Trackio artifact" ) lb_cell_art.add_argument("name", help="Artifact reference project/name:vN") lb_cell_art.add_argument("--title", help="Cell title") lb_cell_art.add_argument("--page", help="Page title or slug") lb_cell_art.add_argument( "--type", dest="artifact_type", help='Artifact type, e.g. "dataset" or "model"', ) lb_cell_code = lb_cell_sub.add_parser("code", help="Append a code cell") lb_cell_code.add_argument("--title", help="Cell title") lb_cell_code.add_argument("--page", help="Page title or slug") lb_cell_code.add_argument( "--code", action="append", default=[], help="Path to code/config to include" ) lb_cell_code.add_argument("--code-text", help="Inline code to include") lb_cell_code.add_argument("--language", help="Language for --code-text") lb_cell_code.add_argument("--output", required=True, help="Output text") lb_cell_figure = lb_cell_sub.add_parser("figure", help="Append a figure cell") lb_cell_figure.add_argument("--title", help="Cell title") lb_cell_figure.add_argument("--page", help="Page title or slug") lb_cell_figure.add_argument("--html", help="Path to HTML, or inline HTML text") lb_cell_figure.add_argument("--html-text", help="Inline HTML text") lb_cell_figure.add_argument("--raw", help="Path/URL/text for raw data") lb_cell_figure.add_argument("--raw-text", help="Inline raw data") lb_cell_dash = lb_cell_sub.add_parser( "dashboard", help="Embed a Trackio dashboard for a project" ) lb_cell_dash.add_argument("project", help="Trackio project name") lb_cell_dash.add_argument( "--space", dest="space_id", help="HF Space id (owner/name) hosting the dashboard", ) lb_cell_dash.add_argument("--title", help="Cell title") lb_cell_dash.add_argument("--page", help="Page title or slug") lb_run = logbook_sub.add_parser( "run", help="Run a command; log the command, its scripts, and output to a page", ) lb_run.add_argument("--page", help="Page title or slug") lb_run.add_argument("--title", help="Cell title") lb_run.add_argument( "--no-artifacts", action="store_true", help="Do not record output model/data files as artifact cells", ) lb_run.add_argument("command", nargs="*") lb_page = logbook_sub.add_parser( "page", help="Create or select a page and make it the default target" ) lb_page.add_argument("title", help="Page title") lb_read = logbook_sub.add_parser( "read", help="Read logbook pages/cells in an agent-friendly form" ) lb_read.add_argument( "--path", help=( "Logbook to read: local path, HF Space id, or URL " "(can also be passed positionally: trackio logbook read )" ), ) lb_read.add_argument("--json", action="store_true", help="Output JSON") lb_read.add_argument( "--head", type=int, default=None, help="Lines of code shown per code cell (default 3; 0 hides code)", ) lb_read.add_argument( "--tail", type=int, default=None, help="Lines of output shown per code cell (default 3; 0 hides output)", ) lb_read.add_argument( "--raw-limit", type=int, default=None, help="Inline figure raw data up to this many chars (default 500; 0 disables)", ) lb_read_sub = lb_read.add_subparsers(dest="read_target") lb_read_pages = lb_read_sub.add_parser("pages", help="List logbook pages") lb_read_pages.add_argument("--json", action="store_true", help="Output JSON") lb_read_page = lb_read_sub.add_parser("page", help="Read a page for agents") lb_read_page.add_argument("page", nargs="?", help="Page title or slug") lb_read_page.add_argument("--json", action="store_true", help="Output JSON") lb_read_page.add_argument( "--head", type=int, default=argparse.SUPPRESS, help="Code lines per code cell" ) lb_read_page.add_argument( "--tail", type=int, default=argparse.SUPPRESS, help="Output lines per code cell" ) lb_read_page.add_argument( "--raw-limit", type=int, default=argparse.SUPPRESS, help="Inline figure raw data up to this many chars", ) lb_read_cell = lb_read_sub.add_parser("cell", help="Read one cell by id") lb_read_cell.add_argument("cell_id", help="Cell id") lb_read_cell.add_argument("--json", action="store_true", help="Output JSON") lb_read_cell.add_argument("--full", action="store_true", help="Include full body") lb_read_cell.add_argument( "--raw", action="store_true", help="Include figure raw data" ) lb_read_cell.add_argument("--html", action="store_true", help="Include figure HTML") lb_serve = logbook_sub.add_parser("serve", help="Preview the logbook locally") lb_serve.add_argument("path", nargs="?", help="Logbook workspace/path to serve") lb_serve.add_argument("--port", type=int, default=7861) lb_serve.add_argument("--no-browser", action="store_true") lb_pub = logbook_sub.add_parser( "publish", help="Publish to a static HF Space (first publish enables auto-sync)" ) lb_pub.add_argument("space_id", nargs="?", help="HF Space id (username/space)") lb_pub.add_argument( "--private", action="store_true", help="Make the logbook and any promoted dashboards/buckets private", ) logbook_sub.add_parser( "sync", help="Push local edits to the Space now (after the first publish)" ) logbook_sub.add_parser("_sync") logbook_sub.add_parser("sync-todos") args, unknown_args = parser.parse_known_args() if unknown_args: trailing_global_parser = argparse.ArgumentParser(add_help=False) trailing_global_parser.add_argument("--space", required=False) trailing_global_parser.add_argument("--hf-token", required=False) trailing_globals, remaining_unknown = trailing_global_parser.parse_known_args( unknown_args ) if remaining_unknown: parser.error(f"unrecognized arguments: {' '.join(remaining_unknown)}") if trailing_globals.space is not None: args.space = trailing_globals.space if trailing_globals.hf_token is not None: args.hf_token = trailing_globals.hf_token if args.command in ("show", "status", "sync", "freeze", "skills") and _get_space( args ): error_exit( f"The '{args.command}' command does not support --space (remote mode)." ) if args.command == "show": color_palette = None if args.color_palette: color_palette = [color.strip() for color in args.color_palette.split(",")] show( project=args.project, theme=args.theme, mcp_server=args.mcp_server, footer=args.footer, color_palette=color_palette, host=args.host, frontend_dir=args.frontend, ) elif args.command == "status": _handle_status() elif args.command == "sync": _handle_sync(args) elif args.command == "freeze": freeze( space_id=args.space_id, project=args.project, new_space_id=args.new_space_id, private=args.private, frontend_dir=args.frontend, ) elif args.command == "config": _handle_config(args) elif args.command == "list": if args.list_type == "spaces": _handle_list_spaces(args) return remote = _get_remote(args) if args.list_type == "projects": if remote: projects = remote.predict(api_name="/get_all_projects") else: projects = SQLiteStorage.get_projects() if args.json: print(format_json({"projects": projects})) else: print(format_list(projects, "Projects")) elif args.list_type == "runs": if remote: run_records = remote.predict( args.project, api_name="/get_runs_for_project" ) runs = [r["name"] if isinstance(r, dict) else r for r in run_records] else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") runs = SQLiteStorage.get_runs(args.project) if args.json: print(format_json({"project": args.project, "runs": runs})) else: print(format_list(runs, f"Runs in '{args.project}'")) elif args.list_type == "metrics": if remote: metrics = remote.predict( args.project, args.run, api_name="/get_metrics_for_run" ) else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") runs = SQLiteStorage.get_runs(args.project) if args.run not in runs: error_exit( f"Run '{args.run}' not found in project '{args.project}'." ) metrics = SQLiteStorage.get_all_metrics_for_run(args.project, args.run) if args.json: print( format_json( {"project": args.project, "run": args.run, "metrics": metrics} ) ) else: print( format_list( metrics, f"Metrics for '{args.run}' in '{args.project}'" ) ) elif args.list_type == "system-metrics": if remote: system_metrics = remote.predict( args.project, args.run, api_name="/get_system_metrics_for_run" ) else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") runs = SQLiteStorage.get_runs(args.project) if args.run not in runs: error_exit( f"Run '{args.run}' not found in project '{args.project}'." ) system_metrics = SQLiteStorage.get_all_system_metrics_for_run( args.project, args.run ) if args.json: print( format_json( { "project": args.project, "run": args.run, "system_metrics": system_metrics, } ) ) else: print(format_system_metric_names(system_metrics)) elif args.list_type == "alerts": if remote: alerts = remote.predict( args.project, args.run, args.level, args.since, api_name="/get_alerts", ) else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") alerts = SQLiteStorage.get_alerts( args.project, run_name=args.run, level=args.level, since=args.since, ) if args.json: print( format_json( { "project": args.project, "run": args.run, "level": args.level, "since": args.since, "alerts": alerts, } ) ) else: print(format_alerts(alerts)) elif args.list_type == "reports": if remote: run_records = remote.predict( args.project, api_name="/get_runs_for_project" ) runs = [r["name"] if isinstance(r, dict) else r for r in run_records] else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") runs = SQLiteStorage.get_runs(args.project) if args.run and args.run not in runs: error_exit(f"Run '{args.run}' not found in project '{args.project}'.") target_runs = [args.run] if args.run else runs all_reports = [] for run_name in target_runs: if remote: logs = remote.predict(args.project, run_name, api_name="/get_logs") else: logs = SQLiteStorage.get_logs(args.project, run_name) all_reports.extend(_extract_reports(run_name, logs)) if args.json: print( format_json( { "project": args.project, "run": args.run, "reports": all_reports, } ) ) else: report_lines = [ f"{entry['run']} | {entry['report']} | step={entry['step']} | {entry['timestamp']}" for entry in all_reports ] if args.run: print( format_list( report_lines, f"Reports for '{args.run}' in '{args.project}'", ) ) else: print(format_list(report_lines, f"Reports in '{args.project}'")) elif args.list_type == "artifacts": if remote: artifacts = remote.predict(args.project, api_name="/get_artifacts") else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") artifacts = SQLiteStorage.get_artifacts(args.project) if args.json: print(format_json({"project": args.project, "artifacts": artifacts})) else: print(format_artifacts(artifacts, args.project)) elif args.command == "get": remote = _get_remote(args) if args.get_type == "artifact": if remote: record = remote.predict( args.project, args.name, args.version, api_name="/get_artifact_manifest", ) else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") record = SQLiteStorage.get_artifact_manifest( args.project, args.name, args.version ) if record is None: spec = f":{args.version}" if args.version else "" error_exit( f"Artifact '{args.name}{spec}' not found in project " f"'{args.project}'." ) if args.json: print(format_json(record)) else: print(format_artifact(record)) elif args.get_type == "project": if remote: summary = remote.predict(args.project, api_name="/get_project_summary") else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") summary = get_project_summary(args.project) if args.json: print(format_json(summary)) else: print(format_project_summary(summary)) elif args.get_type == "run": if remote: summary = remote.predict( args.project, args.run, api_name="/get_run_summary" ) else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") runs = SQLiteStorage.get_runs(args.project) if args.run not in runs: error_exit( f"Run '{args.run}' not found in project '{args.project}'." ) summary = get_run_summary(args.project, args.run) if args.json: print(format_json(summary)) else: print(format_run_summary(summary)) elif args.get_type == "metric": at_time = getattr(args, "at_time", None) if remote: values = remote.predict( args.project, args.run, args.metric, args.step, args.around, at_time, args.window, api_name="/get_metric_values", ) else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") runs = SQLiteStorage.get_runs(args.project) if args.run not in runs: error_exit( f"Run '{args.run}' not found in project '{args.project}'." ) metrics = SQLiteStorage.get_all_metrics_for_run(args.project, args.run) if args.metric not in metrics: error_exit( f"Metric '{args.metric}' not found in run '{args.run}' of project '{args.project}'." ) values = SQLiteStorage.get_metric_values( args.project, args.run, args.metric, step=args.step, around_step=args.around, at_time=at_time, window=args.window, ) if args.json: print( format_json( { "project": args.project, "run": args.run, "metric": args.metric, "values": values, } ) ) else: print(format_metric_values(values)) elif args.get_type == "snapshot": if not args.step and not args.around and not getattr(args, "at_time", None): error_exit( "Provide --step, --around (with --window), or --at-time (with --window)." ) at_time = getattr(args, "at_time", None) if remote: snapshot = remote.predict( args.project, args.run, args.step, args.around, at_time, args.window, api_name="/get_snapshot", ) else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") runs = SQLiteStorage.get_runs(args.project) if args.run not in runs: error_exit( f"Run '{args.run}' not found in project '{args.project}'." ) snapshot = SQLiteStorage.get_snapshot( args.project, args.run, step=args.step, around_step=args.around, at_time=at_time, window=args.window, ) if args.json: result = { "project": args.project, "run": args.run, "metrics": snapshot, } if args.step is not None: result["step"] = args.step if args.around is not None: result["around"] = args.around result["window"] = args.window if at_time is not None: result["at_time"] = at_time result["window"] = args.window print(format_json(result)) else: print(format_snapshot(snapshot)) elif args.get_type == "system-metric": if remote: system_metrics = remote.predict( args.project, args.run, api_name="/get_system_logs" ) if args.metric: all_system_metric_names = remote.predict( args.project, args.run, api_name="/get_system_metrics_for_run", ) if args.metric not in all_system_metric_names: error_exit( f"System metric '{args.metric}' not found in run '{args.run}' of project '{args.project}'." ) filtered_metrics = [ { k: v for k, v in entry.items() if k == "timestamp" or k == args.metric } for entry in system_metrics if args.metric in entry ] if args.json: print( format_json( { "project": args.project, "run": args.run, "metric": args.metric, "values": filtered_metrics, } ) ) else: print(format_system_metrics(filtered_metrics)) else: if args.json: print( format_json( { "project": args.project, "run": args.run, "system_metrics": system_metrics, } ) ) else: print(format_system_metrics(system_metrics)) else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") runs = SQLiteStorage.get_runs(args.project) if args.run not in runs: error_exit( f"Run '{args.run}' not found in project '{args.project}'." ) if args.metric: system_metrics = SQLiteStorage.get_system_logs( args.project, args.run ) all_system_metric_names = ( SQLiteStorage.get_all_system_metrics_for_run( args.project, args.run ) ) if args.metric not in all_system_metric_names: error_exit( f"System metric '{args.metric}' not found in run '{args.run}' of project '{args.project}'." ) filtered_metrics = [ { k: v for k, v in entry.items() if k == "timestamp" or k == args.metric } for entry in system_metrics if args.metric in entry ] if args.json: print( format_json( { "project": args.project, "run": args.run, "metric": args.metric, "values": filtered_metrics, } ) ) else: print(format_system_metrics(filtered_metrics)) else: system_metrics = SQLiteStorage.get_system_logs( args.project, args.run ) if args.json: print( format_json( { "project": args.project, "run": args.run, "system_metrics": system_metrics, } ) ) else: print(format_system_metrics(system_metrics)) elif args.get_type == "alerts": if remote: alerts = remote.predict( args.project, args.run, args.level, args.since, api_name="/get_alerts", ) else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") alerts = SQLiteStorage.get_alerts( args.project, run_name=args.run, level=args.level, since=args.since, ) if args.json: print( format_json( { "project": args.project, "run": args.run, "level": args.level, "since": args.since, "alerts": alerts, } ) ) else: print(format_alerts(alerts)) elif args.get_type == "report": if remote: logs = remote.predict(args.project, args.run, api_name="/get_logs") else: db_path = SQLiteStorage.get_project_db_path(args.project) if not db_path.exists(): error_exit(f"Project '{args.project}' not found.") runs = SQLiteStorage.get_runs(args.project) if args.run not in runs: error_exit( f"Run '{args.run}' not found in project '{args.project}'." ) logs = SQLiteStorage.get_logs(args.project, args.run) reports = _extract_reports(args.run, logs, report_name=args.report) if not reports: error_exit( f"Report '{args.report}' not found in run '{args.run}' of project '{args.project}'." ) if args.json: print( format_json( { "project": args.project, "run": args.run, "report": args.report, "values": reports, } ) ) else: output = [] for idx, entry in enumerate(reports, start=1): output.append( f"Entry {idx} | step={entry['step']} | timestamp={entry['timestamp']}" ) output.append(entry["content"]) if idx < len(reports): output.append("-" * 80) print("\n".join(output)) elif args.command == "query": if args.query_type == "project": _handle_query(args) elif args.command == "skills": if args.skills_action == "add": _handle_skills_add(args) elif args.command == "logbook": _handle_logbook(args) else: parser.print_help() def _sync_suffix(lb, proj): if lb.is_autosync(proj): space = lb.read_metadata(proj).get("space_id") return f" · syncing to {space}…" return "" def _logbook_cell_target(lb, proj, args): return lb.resolve_page(proj, getattr(args, "page", None)) def _print_logbook_pages(pages): if not pages: print("No pages.") return print("Pages:") for page in pages: count = page["cell_count"] print( f"- {page['slug']} · {page['title']} · " f"{count} cell{'' if count == 1 else 's'}" ) def _print_logbook_page_outline(page): print(f"Page: {page['title']} ({page['slug']})") if not page["cells"]: print("No cells.") return for cell in page["cells"]: created = ( f" · {cell['created_at'].replace('T', ' ')[:16]}" if cell.get("created_at") else "" ) print(f"\n### {cell['title']} · {cell['type']} · {cell['id']}{created}") if cell.get("preview"): print(cell["preview"].rstrip()) print( "\nFetch full payloads with: trackio logbook read cell " "[--full|--raw|--html]" ) def _print_logbook_cell(cell): print(f"Cell: {cell['title']} ({cell['id']})") print(f"Page: {cell['page_title']} ({cell['page']})") print(f"Type: {cell['type']}") if cell.get("created_at"): print(f"Created: {cell['created_at']}") content_keys = [key for key in ("body", "raw", "html") if key in cell] if not content_keys: if cell["type"] == "code": print("\nBody omitted. Use --full to include code/output.") elif cell["type"] == "figure": print("\nFigure content omitted. Use --raw, --html, or --full.") return for key in content_keys: label = key.upper() print(f"\n--- {label} ---\n") print(cell[key]) def _read_logbook_payload(path_or_text, inline_text): if inline_text is not None: return inline_text if not path_or_text: return "" path = Path(path_or_text) if path.is_file(): return path.read_text(encoding="utf-8") return path_or_text def _handle_logbook(args): from trackio import logbook as lb action = args.logbook_action try: if action == "open": print("Warning: Trackio Logbook is an experimental feature.") proj = lb.find_project_dir() if proj is not None: if args.space_id: metadata = lb.read_metadata(proj) metadata["space_id"] = args.space_id lb.write_metadata(proj, metadata) print(f"Attached to existing logbook at {lb.logbook_root(proj)}") if args.title: print( "Note: --title was ignored because this logbook already " "exists. To retitle it, edit the '# ...' heading in " f"{lb.logbook_root(proj) / 'pages' / 'index.md'}." ) elif args.space_id: proj = lb.clone_logbook(args.space_id) if proj is not None: print( f"Cloned logbook from {args.space_id} into " f"{lb.logbook_root(proj)}" ) if proj is None: proj = lb.create_logbook( args.title or os.path.basename(os.getcwd()), space_id=args.space_id ) print(f"Opened logbook at {lb.logbook_root(proj)}") if args.space_id: print(f"Will publish to: {args.space_id}") if not args.no_serve: lb.start_preview(proj, port=args.port, open_browser=not args.no_browser) elif action == "run": proj = lb.require_project_dir() command = list(args.command or []) if command and command[0] == "--": command = command[1:] if not command: error_exit("No command provided. Use: trackio logbook run -- ") rc = lb.run_and_log( proj, command, page=args.page, title=args.title, capture_artifacts=not getattr(args, "no_artifacts", False), ) slug = lb.read_metadata(proj).get("last_page", "?") print(f"Logged run to page '{slug}'.{_sync_suffix(lb, proj)}") sys.exit(rc) elif action == "cell": proj = lb.require_project_dir() slug = _logbook_cell_target(lb, proj, args) if args.cell_type == "markdown": lb.add_markdown_cell(proj, slug, args.body, title=args.title) elif args.cell_type == "artifact": lb.add_artifact_cell( proj, slug, args.name, title=args.title, artifact_type=args.artifact_type, ) elif args.cell_type == "code": lb.add_code_cell( proj, slug, args.output, title=args.title, code_paths=args.code, code_text=args.code_text, language=args.language, ) elif args.cell_type == "figure": html = _read_logbook_payload(args.html, args.html_text) raw = _read_logbook_payload(args.raw, args.raw_text) lb.add_figure_cell( proj, slug, html=html, raw=raw, title=args.title, ) elif args.cell_type == "dashboard": lb.add_dashboard_cell( proj, slug, args.project, space_id=args.space_id, title=args.title, ) print( f"Logged {args.cell_type} cell to page " f"'{slug}'.{_sync_suffix(lb, proj)}" ) lb.trigger_autosync(proj) elif action == "page": proj = lb.require_project_dir() page_slug = lb.ensure_page(proj, args.title) print(f"Selected page '{page_slug}' as default.{_sync_suffix(lb, proj)}") lb.trigger_autosync(proj) elif action == "read": proj = lb.resolve_read_source(args.path) preview_opts = { "head": lb.DEFAULT_HEAD if getattr(args, "head", None) is None else args.head, "tail": lb.DEFAULT_TAIL if getattr(args, "tail", None) is None else args.tail, "raw_limit": lb.DEFAULT_RAW_LIMIT if getattr(args, "raw_limit", None) is None else args.raw_limit, } if args.read_target is None: if args.json: print(format_json(lb.read_logbook_data(proj, **preview_opts))) else: print(lb.read_logbook(proj, **preview_opts)) elif args.read_target == "pages": pages = lb.list_pages(proj) if args.json: print(format_json({"pages": pages})) else: _print_logbook_pages(pages) elif args.read_target == "page": page = lb.read_page_outline(proj, args.page, **preview_opts) if args.json: print(format_json(page)) else: _print_logbook_page_outline(page) elif args.read_target == "cell": cell = lb.read_cell( proj, args.cell_id, include_full=args.full, include_raw=args.raw, include_html=args.html, ) if args.json: print(format_json(cell)) else: _print_logbook_cell(cell) elif action == "_sync": lb.sync_worker() elif action == "sync-todos": lb.sync_todos_from_stdin() elif action == "serve": lb.serve(path=args.path, port=args.port, open_browser=not args.no_browser) elif action == "publish": url = lb.publish(space_id=args.space_id, private=args.private) print(f"Published: {url}") space_id = lb.read_metadata(lb.require_project_dir()).get("space_id") if space_id: subdomain = re.sub(r"[^a-z0-9]+", "-", space_id.lower()).strip("-") print(f"Rendered at: https://{subdomain}.static.hf.space/") elif action == "sync": proj = lb.require_project_dir() if not lb.is_autosync(proj): error_exit("Publish first: trackio logbook publish ") lb.trigger_autosync(proj) print(f"Syncing to {lb.read_metadata(proj).get('space_id')}…") except lb.LogbookError as e: error_exit(str(e)) def _handle_skills_add(args): import shutil from pathlib import Path CENTRAL_LOCAL = Path(".agents/skills") CENTRAL_GLOBAL = Path("~/.agents/skills") CLAUDE_LOCAL = Path(".claude/skills") CLAUDE_GLOBAL = Path("~/.claude/skills") SKILL_ID = "trackio" GITHUB_RAW = "https://raw.githubusercontent.com/gradio-app/trackio/main" SKILL_PREFIX = ".agents/skills/trackio" SKILL_FILES = [ "SKILL.md", "alerts.md", "logging_metrics.md", "retrieving_metrics.md", "storage_schema.md", "logbook.md", ] COMMAND_PREFIX = ".agents/commands" COMMAND_FILE = "logbook.md" REPO_ROOT = Path(__file__).resolve().parent.parent USE_LOCAL = (REPO_ROOT / SKILL_PREFIX / "SKILL.md").is_file() if USE_LOCAL: print(f"Using local Trackio source at {REPO_ROOT}") def download(url: str) -> str: from huggingface_hub.utils import get_session try: response = get_session().get(url) response.raise_for_status() except Exception as e: error_exit( f"Failed to download {url}\n{e}\n\n" "Make sure you have internet access. The skill files are fetched from " "the Trackio GitHub repository." ) return response.text def get_content(prefix: str, fname: str) -> str: if USE_LOCAL: local = REPO_ROOT / prefix / fname if local.is_file(): return local.read_text(encoding="utf-8") return download(f"{GITHUB_RAW}/{prefix}/{fname}") def remove_existing(path: Path, force: bool): if not (path.exists() or path.is_symlink()): return if not force: error_exit( f"Skill already exists at {path}.\nRe-run with --force to overwrite." ) if path.is_dir() and not path.is_symlink(): shutil.rmtree(path) else: path.unlink() def install_to(skills_dir: Path, force: bool) -> Path: skills_dir = skills_dir.expanduser().resolve() skills_dir.mkdir(parents=True, exist_ok=True) dest = skills_dir / SKILL_ID remove_existing(dest, force) dest.mkdir() for fname in SKILL_FILES: (dest / fname).write_text( get_content(SKILL_PREFIX, fname), encoding="utf-8" ) return dest def create_symlink( agent_skills_dir: Path, central_skill_path: Path, force: bool ) -> Path: agent_skills_dir = agent_skills_dir.expanduser().resolve() agent_skills_dir.mkdir(parents=True, exist_ok=True) link_path = agent_skills_dir / SKILL_ID remove_existing(link_path, force) link_path.symlink_to(os.path.relpath(central_skill_path, agent_skills_dir)) return link_path def install_command( command_dir: Path, force: bool, strip_frontmatter: bool = False ) -> Path: import re command_dir = command_dir.expanduser().resolve() command_dir.mkdir(parents=True, exist_ok=True) dest = command_dir / COMMAND_FILE if dest.exists() and not force: error_exit( f"Command already exists at {dest}.\nRe-run with --force to overwrite." ) content = get_content(COMMAND_PREFIX, COMMAND_FILE) if strip_frontmatter: content = re.sub(r"\A---\n.*?\n---\n+", "", content, flags=re.S) dest.write_text(content, encoding="utf-8") return dest global_targets = { "cursor": Path("~/.cursor/skills"), "claude": CLAUDE_GLOBAL, "codex": Path("~/.codex/skills"), "opencode": Path("~/.opencode/skills"), "pi": Path("~/.pi/agent/skills"), } local_targets = { "cursor": Path(".cursor/skills"), "claude": CLAUDE_LOCAL, "codex": Path(".codex/skills"), "opencode": Path(".opencode/skills"), "pi": Path(".pi/skills"), } targets_dict = global_targets if args.global_ else local_targets command_targets_global = { "cursor": Path("~/.cursor/commands"), "claude": Path("~/.claude/commands"), "codex": Path("~/.codex/prompts"), "opencode": Path("~/.config/opencode/commands"), "pi": Path("~/.pi/agent/prompts"), } command_targets_local = { "cursor": Path(".cursor/commands"), "claude": Path(".claude/commands"), "codex": Path("~/.codex/prompts"), "opencode": Path(".opencode/commands"), "pi": Path(".pi/prompts"), } command_dict = command_targets_global if args.global_ else command_targets_local COMMAND_NOTES = { "codex": " (Codex only reads ~/.codex/prompts; restart Codex to pick it up)" } STRIP_FRONTMATTER = {"pi"} if args.dest: if ( args.cursor or args.claude or args.codex or args.opencode or args.pi or args.global_ ): error_exit("--dest cannot be combined with agent flags or --global.") skill_dest = install_to(Path(args.dest), args.force) print(f"Installed '{SKILL_ID}' to {skill_dest}") return selected = [ name for name, flag in ( ("cursor", args.cursor), ("claude", args.claude), ("codex", args.codex), ("opencode", args.opencode), ("pi", args.pi), ) if flag ] central_path = CENTRAL_GLOBAL if args.global_ else CENTRAL_LOCAL central_skill_path = install_to(central_path, args.force) print(f"Installed '{SKILL_ID}' to central location: {central_skill_path}") for name in selected: link_path = create_symlink(targets_dict[name], central_skill_path, args.force) print(f"Created symlink: {link_path}") if not args.no_command: for name in selected: if name in command_dict: cmd_path = install_command( command_dict[name], args.force, strip_frontmatter=name in STRIP_FRONTMATTER, ) note = COMMAND_NOTES.get(name, "") print(f"Installed '/logbook' command: {cmd_path}{note}") else: print( f"Skipped '/logbook' command for {name} " "(no slash-command directory convention)." ) if args.claude and not args.no_hook: hook_path = _install_claude_logbook_hook(args.global_) if hook_path: print(f"Installed logbook todo-sync hook: {hook_path}") def _install_claude_logbook_hook(global_: bool): import json from pathlib import Path settings = ( Path("~/.claude/settings.json") if global_ else Path(".claude/settings.json") ).expanduser() settings.parent.mkdir(parents=True, exist_ok=True) data = {} if settings.exists(): try: data = json.loads(settings.read_text(encoding="utf-8")) except Exception: print(f"Could not parse {settings}; skipping hook install.") return None command = "trackio logbook sync-todos" hooks = data.setdefault("hooks", {}) post = hooks.setdefault("PostToolUse", []) for entry in post: for h in entry.get("hooks", []): if h.get("command") == command: return settings post.append( { "matcher": "TodoWrite", "hooks": [{"type": "command", "command": command}], } ) settings.write_text(json.dumps(data, indent=2), encoding="utf-8") return settings if __name__ == "__main__": main()