From 864cada65e71be57c967ee41e876aaca6fef9356 Mon Sep 17 00:00:00 2001 From: gilesb Date: Wed, 7 Jan 2026 10:57:45 +0000 Subject: [PATCH] feat: Art DAG CLI client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - artdag.py CLI for L1 server interaction - run: start rendering jobs - runs: list all runs - status: check run status - cache/view: manage cached content (pipe to mpv with -o -) - assets: list known assets - import: add files to cache 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 4 + README.md | 98 ++++++++++++++++++++ artdag.py | 233 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 4 files changed, 337 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 artdag.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65776d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc002ec --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Art DAG Client + +CLI for interacting with the Art DAG L1 rendering server. + +## Setup + +```bash +pip install -r requirements.txt +``` + +## Usage + +```bash +# Set server URL (default: http://localhost:8100) +export ARTDAG_SERVER=http://localhost:8100 + +# Or pass with every command +./artdag.py --server http://localhost:8100 +``` + +## Commands + +### Server Info +```bash +./artdag.py info +``` + +### List Known Assets +```bash +./artdag.py assets +``` + +### Start a Rendering Run +```bash +# Using asset name +./artdag.py run dog cat + +# Using content hash +./artdag.py run dog 33268b6e167deaf018cc538de12dbe562612b33e89a749391cef855b320a269b + +# Wait for completion +./artdag.py run dog cat --wait + +# Custom output name +./artdag.py run dog cat --name my-dog-video +``` + +### List Runs +```bash +./artdag.py runs +./artdag.py runs --limit 20 +``` + +### Check Run Status +```bash +./artdag.py status +``` + +### List Cached Content +```bash +./artdag.py cache +``` + +### View/Download Cached Content +```bash +# Show info +./artdag.py view + +# Download to file +./artdag.py view -o output.mkv + +# Pipe to mpv (use -o - for stdout) +./artdag.py view -o - | mpv - +``` + +### Import Local File to Cache +```bash +./artdag.py import /path/to/file.jpg +``` + +## Example Workflow + +```bash +# Check server +./artdag.py info + +# See available assets +./artdag.py assets + +# Run dog effect on cat, wait for result +./artdag.py run dog cat --wait + +# List completed runs +./artdag.py runs + +# Download the output +./artdag.py view -o result.mkv +``` diff --git a/artdag.py b/artdag.py new file mode 100755 index 0000000..262c67f --- /dev/null +++ b/artdag.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +""" +Art DAG Client + +CLI for interacting with the Art DAG L1 server. +""" + +import json +import os +import sys +import time +from pathlib import Path + +import click +import requests + +DEFAULT_SERVER = os.environ.get("ARTDAG_SERVER", "http://localhost:8100") + + +def get_server(): + """Get server URL from env or default.""" + return DEFAULT_SERVER + + +def api_get(path: str): + """GET request to server.""" + resp = requests.get(f"{get_server()}{path}") + resp.raise_for_status() + return resp.json() + + +def api_post(path: str, data: dict = None, params: dict = None): + """POST request to server.""" + resp = requests.post(f"{get_server()}{path}", json=data, params=params) + resp.raise_for_status() + return resp.json() + + +@click.group() +@click.option("--server", "-s", envvar="ARTDAG_SERVER", default=DEFAULT_SERVER, + help="L1 server URL") +@click.pass_context +def cli(ctx, server): + """Art DAG Client - interact with L1 rendering server.""" + ctx.ensure_object(dict) + ctx.obj["server"] = server + global DEFAULT_SERVER + DEFAULT_SERVER = server + + +@cli.command() +def info(): + """Show server info.""" + data = api_get("/") + click.echo(f"Server: {get_server()}") + click.echo(f"Name: {data['name']}") + click.echo(f"Version: {data['version']}") + click.echo(f"Cache: {data['cache_dir']}") + click.echo(f"Runs: {data['runs_count']}") + + +@cli.command() +@click.argument("recipe") +@click.argument("input_hash") +@click.option("--name", "-n", help="Output name") +@click.option("--wait", "-w", is_flag=True, help="Wait for completion") +def run(recipe, input_hash, name, wait): + """Start a rendering run. + + RECIPE: Effect/recipe to apply (e.g., dog, identity) + INPUT_HASH: Content hash of input asset + """ + # Resolve named assets + assets = api_get("/assets") + if input_hash in assets: + input_hash = assets[input_hash] + click.echo(f"Resolved input to: {input_hash[:16]}...") + + data = { + "recipe": recipe, + "inputs": [input_hash], + } + if name: + data["output_name"] = name + + result = api_post("/runs", data) + run_id = result["run_id"] + + click.echo(f"Run started: {run_id}") + click.echo(f"Status: {result['status']}") + + if wait: + click.echo("Waiting for completion...") + while True: + status = api_get(f"/runs/{run_id}") + if status["status"] in ("completed", "failed"): + break + time.sleep(1) + click.echo(".", nl=False) + click.echo() + + if status["status"] == "completed": + click.echo(f"Completed!") + click.echo(f"Output: {status['output_hash']}") + else: + click.echo(f"Failed: {status.get('error', 'Unknown error')}") + + +@cli.command("runs") +@click.option("--limit", "-l", default=10, help="Max runs to show") +def list_runs(limit): + """List all runs.""" + runs = api_get("/runs") + + if not runs: + click.echo("No runs found.") + return + + click.echo(f"{'ID':<36} {'Status':<10} {'Recipe':<10} {'Output Hash':<20}") + click.echo("-" * 80) + + for run in runs[:limit]: + output = run.get("output_hash", "")[:16] + "..." if run.get("output_hash") else "-" + click.echo(f"{run['run_id']} {run['status']:<10} {run['recipe']:<10} {output}") + + +@cli.command() +@click.argument("run_id") +def status(run_id): + """Get status of a run.""" + try: + run = api_get(f"/runs/{run_id}") + except requests.HTTPError as e: + if e.response.status_code == 404: + click.echo(f"Run not found: {run_id}") + return + raise + + click.echo(f"Run ID: {run['run_id']}") + click.echo(f"Status: {run['status']}") + click.echo(f"Recipe: {run['recipe']}") + click.echo(f"Inputs: {', '.join(run['inputs'])}") + click.echo(f"Output Name: {run['output_name']}") + click.echo(f"Created: {run['created_at']}") + + if run.get("completed_at"): + click.echo(f"Completed: {run['completed_at']}") + + if run.get("output_hash"): + click.echo(f"Output Hash: {run['output_hash']}") + + if run.get("error"): + click.echo(f"Error: {run['error']}") + + +@cli.command() +@click.option("--limit", "-l", default=20, help="Max items to show") +def cache(limit): + """List cached content.""" + items = api_get("/cache") + + if not items: + click.echo("Cache is empty.") + return + + click.echo(f"Cached content ({len(items)} items):") + for item in items[:limit]: + click.echo(f" {item}") + + +@cli.command() +@click.argument("content_hash") +@click.option("--output", "-o", type=click.Path(), help="Save to file (use - for stdout)") +def view(content_hash, output): + """View or download cached content. + + Use -o - to pipe to stdout, e.g.: artdag view -o - | mpv - + """ + url = f"{get_server()}/cache/{content_hash}" + + try: + if output == "-": + # Stream to stdout for piping + resp = requests.get(url, stream=True) + resp.raise_for_status() + for chunk in resp.iter_content(chunk_size=8192): + sys.stdout.buffer.write(chunk) + elif output: + # Download to file + resp = requests.get(url, stream=True) + resp.raise_for_status() + with open(output, "wb") as f: + for chunk in resp.iter_content(chunk_size=8192): + f.write(chunk) + click.echo(f"Saved to: {output}", err=True) + else: + # Get info via GET with stream to check size + resp = requests.get(url, stream=True) + resp.raise_for_status() + size = resp.headers.get("content-length", "unknown") + content_type = resp.headers.get("content-type", "unknown") + click.echo(f"Hash: {content_hash}") + click.echo(f"Size: {size} bytes") + click.echo(f"Type: {content_type}") + click.echo(f"URL: {url}") + resp.close() + except requests.HTTPError as e: + if e.response.status_code == 404: + click.echo(f"Not found: {content_hash}", err=True) + else: + raise + + +@cli.command("import") +@click.argument("filepath", type=click.Path(exists=True)) +def import_file(filepath): + """Import a local file to cache.""" + path = str(Path(filepath).resolve()) + result = api_post("/cache/import", params={"path": path}) + click.echo(f"Imported: {result['content_hash']}") + + +@cli.command() +def assets(): + """List known assets.""" + data = api_get("/assets") + click.echo("Known assets:") + for name, hash in data.items(): + click.echo(f" {name}: {hash[:16]}...") + + +if __name__ == "__main__": + cli() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b4d3676 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +click>=8.0.0 +requests>=2.31.0