From d185683e93525897221dc5ee4dcb12e7c58ac6d6 Mon Sep 17 00:00:00 2001 From: gilesb Date: Thu, 8 Jan 2026 03:18:17 +0000 Subject: [PATCH] Add config CLI commands - Add PyYAML dependency - Add upload-config: upload config YAML files - Add configs: list uploaded configs - Add config: show config details - Add run-config: run config with variable inputs - Add delete-config: delete configs (with pinning check) Example workflow: artdag upload-config recipe.yaml artdag configs artdag run-config -i node_id:content_hash --wait Co-Authored-By: Claude Opus 4.5 --- artdag.py | 215 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 2 files changed, 216 insertions(+) diff --git a/artdag.py b/artdag.py index 9816312..65792ff 100755 --- a/artdag.py +++ b/artdag.py @@ -13,6 +13,7 @@ from pathlib import Path import click import requests +import yaml DEFAULT_SERVER = os.environ.get("ARTDAG_SERVER", "http://localhost:8100") DEFAULT_L2_SERVER = os.environ.get("ARTDAG_L2", "http://localhost:8200") @@ -875,5 +876,219 @@ def collection_delete(name): click.echo(f"Deleted collection: {name}") +# ============ Config Commands ============ + +@cli.command("upload-config") +@click.argument("filepath", type=click.Path(exists=True)) +def upload_config(filepath): + """Upload a config YAML file. Requires login.""" + token_data = load_token() + if not token_data.get("access_token"): + click.echo("Not logged in. Please run: artdag login ", err=True) + sys.exit(1) + + # Validate YAML locally first + with open(filepath) as f: + try: + config = yaml.safe_load(f) + except yaml.YAMLError as e: + click.echo(f"Invalid YAML: {e}", err=True) + sys.exit(1) + + # Check required fields + if not config.get("name"): + click.echo("Config must have a 'name' field", err=True) + sys.exit(1) + + # Upload + try: + with open(filepath, "rb") as f: + files = {"file": (Path(filepath).name, f)} + headers = {"Authorization": f"Bearer {token_data['access_token']}"} + resp = requests.post(f"{get_server()}/configs/upload", files=files, headers=headers) + if resp.status_code == 401: + click.echo("Authentication failed. Please login again.", err=True) + sys.exit(1) + resp.raise_for_status() + result = resp.json() + + click.echo(f"Uploaded config: {result['name']} v{result.get('version', '1.0')}") + click.echo(f"Config ID: {result['config_id']}") + click.echo(f"Variable inputs: {result['variable_inputs']}") + click.echo(f"Fixed inputs: {result['fixed_inputs']}") + except requests.RequestException as e: + click.echo(f"Upload failed: {e}", err=True) + sys.exit(1) + + +@cli.command("configs") +@click.option("--limit", "-l", default=10, help="Max configs to show") +def list_configs(limit): + """List uploaded configs.""" + try: + resp = requests.get(f"{get_server()}/configs") + resp.raise_for_status() + data = resp.json() + except requests.RequestException as e: + click.echo(f"Failed to list configs: {e}", err=True) + sys.exit(1) + + configs = data.get("configs", []) + if not configs: + click.echo("No configs found.") + return + + click.echo(f"{'Name':<20} {'Version':<8} {'Variables':<10} {'Config ID':<24}") + click.echo("-" * 70) + + for config in configs[:limit]: + config_id = config["config_id"][:20] + "..." + var_count = len(config.get("variable_inputs", [])) + click.echo(f"{config['name']:<20} {config['version']:<8} {var_count:<10} {config_id}") + + +@cli.command("config") +@click.argument("config_id") +def show_config(config_id): + """Show details of a config.""" + try: + resp = requests.get(f"{get_server()}/configs/{config_id}") + if resp.status_code == 404: + click.echo(f"Config not found: {config_id}", err=True) + sys.exit(1) + resp.raise_for_status() + config = resp.json() + except requests.RequestException as e: + click.echo(f"Failed to get config: {e}", err=True) + sys.exit(1) + + click.echo(f"Name: {config['name']}") + click.echo(f"Version: {config['version']}") + click.echo(f"Description: {config.get('description', 'N/A')}") + click.echo(f"Config ID: {config['config_id']}") + click.echo(f"Owner: {config.get('owner', 'N/A')}") + click.echo(f"Uploaded: {config['uploaded_at']}") + + if config.get("variable_inputs"): + click.echo("\nVariable Inputs:") + for inp in config["variable_inputs"]: + req = "*" if inp.get("required", True) else "" + click.echo(f" - {inp['name']}{req}: {inp.get('description', 'No description')}") + + if config.get("fixed_inputs"): + click.echo("\nFixed Inputs:") + for inp in config["fixed_inputs"]: + click.echo(f" - {inp['asset']}: {inp['content_hash'][:16]}...") + + +@cli.command("run-config") +@click.argument("config_id") +@click.option("--input", "-i", "inputs", multiple=True, help="Input as node_id:content_hash") +@click.option("--wait", "-w", is_flag=True, help="Wait for completion") +def run_config(config_id, inputs, wait): + """Run a config with variable inputs. Requires login. + + CONFIG_ID: The config ID (content hash) + + Example: artdag run-config abc123 -i source_image:def456 + """ + token_data = load_token() + if not token_data.get("access_token"): + click.echo("Not logged in. Please run: artdag login ", err=True) + sys.exit(1) + + # Parse inputs + input_dict = {} + for inp in inputs: + if ":" not in inp: + click.echo(f"Invalid input format: {inp} (expected node_id:content_hash)", err=True) + sys.exit(1) + node_id, content_hash = inp.split(":", 1) + input_dict[node_id] = content_hash + + # Run + try: + headers = {"Authorization": f"Bearer {token_data['access_token']}"} + resp = requests.post( + f"{get_server()}/configs/{config_id}/run", + json={"inputs": input_dict}, + headers=headers + ) + if resp.status_code == 401: + click.echo("Authentication failed. Please login again.", err=True) + sys.exit(1) + if resp.status_code == 400: + error = resp.json().get("detail", "Bad request") + click.echo(f"Error: {error}", err=True) + sys.exit(1) + if resp.status_code == 404: + click.echo(f"Config not found: {config_id}", err=True) + sys.exit(1) + resp.raise_for_status() + result = resp.json() + except requests.RequestException as e: + click.echo(f"Run failed: {e}", err=True) + sys.exit(1) + + click.echo(f"Run started: {result['run_id']}") + click.echo(f"Recipe: {result['recipe']}") + click.echo(f"Status: {result['status']}") + + if wait: + click.echo("Waiting for completion...") + run_id = result["run_id"] + while True: + time.sleep(2) + try: + resp = requests.get(f"{get_server()}/runs/{run_id}") + resp.raise_for_status() + run = resp.json() + except requests.RequestException: + continue + + if run["status"] == "completed": + click.echo(f"Completed! Output: {run.get('output_hash', 'N/A')}") + break + elif run["status"] == "failed": + click.echo(f"Failed: {run.get('error', 'Unknown error')}", err=True) + sys.exit(1) + + +@cli.command("delete-config") +@click.argument("config_id") +@click.option("--force", "-f", is_flag=True, help="Skip confirmation") +def delete_config(config_id, force): + """Delete a config. Requires login.""" + token_data = load_token() + if not token_data.get("access_token"): + click.echo("Not logged in. Please run: artdag login ", err=True) + sys.exit(1) + + if not force: + if not click.confirm(f"Delete config {config_id[:16]}...?"): + click.echo("Cancelled.") + return + + try: + headers = {"Authorization": f"Bearer {token_data['access_token']}"} + resp = requests.delete(f"{get_server()}/configs/{config_id}", headers=headers) + if resp.status_code == 401: + click.echo("Authentication failed. Please login again.", err=True) + sys.exit(1) + if resp.status_code == 400: + error = resp.json().get("detail", "Cannot delete") + click.echo(f"Error: {error}", err=True) + sys.exit(1) + if resp.status_code == 404: + click.echo(f"Config not found: {config_id}", err=True) + sys.exit(1) + resp.raise_for_status() + except requests.RequestException as e: + click.echo(f"Delete failed: {e}", err=True) + sys.exit(1) + + click.echo(f"Deleted config: {config_id[:16]}...") + + if __name__ == "__main__": cli() diff --git a/requirements.txt b/requirements.txt index b4d3676..3fb1204 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ click>=8.0.0 requests>=2.31.0 +PyYAML>=6.0