- Display CID instead of content hash after upload - Update example recipe syntax to use :cid Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1569 lines
54 KiB
Python
Executable File
1569 lines
54 KiB
Python
Executable File
#!/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
|
|
import yaml
|
|
|
|
DEFAULT_SERVER = os.environ.get("ARTDAG_SERVER", "http://localhost:8100")
|
|
DEFAULT_L2_SERVER = os.environ.get("ARTDAG_L2", "http://localhost:8200")
|
|
CONFIG_DIR = Path.home() / ".artdag"
|
|
TOKEN_FILE = CONFIG_DIR / "token.json"
|
|
|
|
|
|
def get_server():
|
|
"""Get server URL from env or default."""
|
|
return DEFAULT_SERVER
|
|
|
|
|
|
def get_l2_server():
|
|
"""Get L2 server URL."""
|
|
return DEFAULT_L2_SERVER
|
|
|
|
|
|
def load_token() -> dict:
|
|
"""Load saved token from config."""
|
|
if TOKEN_FILE.exists():
|
|
with open(TOKEN_FILE) as f:
|
|
return json.load(f)
|
|
return {}
|
|
|
|
|
|
def save_token(token_data: dict):
|
|
"""Save token to config."""
|
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
with open(TOKEN_FILE, "w") as f:
|
|
json.dump(token_data, f, indent=2)
|
|
TOKEN_FILE.chmod(0o600)
|
|
|
|
|
|
def clear_token():
|
|
"""Clear saved token."""
|
|
if TOKEN_FILE.exists():
|
|
TOKEN_FILE.unlink()
|
|
|
|
|
|
def get_auth_header() -> dict:
|
|
"""Get Authorization header if token exists."""
|
|
token_data = load_token()
|
|
token = token_data.get("access_token")
|
|
if token:
|
|
return {"Authorization": f"Bearer {token}"}
|
|
return {}
|
|
|
|
|
|
def api_get(path: str, auth: bool = False):
|
|
"""GET request to server."""
|
|
headers = get_auth_header() if auth else {}
|
|
resp = requests.get(f"{get_server()}{path}", headers=headers)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
def api_post(path: str, data: dict = None, params: dict = None, auth: bool = False):
|
|
"""POST request to server."""
|
|
headers = get_auth_header() if auth else {}
|
|
resp = requests.post(f"{get_server()}{path}", json=data, params=params, headers=headers)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
@click.group()
|
|
@click.option("--server", "-s", envvar="ARTDAG_SERVER", default=DEFAULT_SERVER,
|
|
help="L1 server URL")
|
|
@click.option("--l2", envvar="ARTDAG_L2", default=DEFAULT_L2_SERVER,
|
|
help="L2 server URL")
|
|
@click.pass_context
|
|
def cli(ctx, server, l2):
|
|
"""Art DAG Client - interact with L1 rendering server."""
|
|
ctx.ensure_object(dict)
|
|
ctx.obj["server"] = server
|
|
ctx.obj["l2"] = l2
|
|
global DEFAULT_SERVER, DEFAULT_L2_SERVER
|
|
DEFAULT_SERVER = server
|
|
DEFAULT_L2_SERVER = l2
|
|
|
|
|
|
# ============ Auth Commands ============
|
|
|
|
@cli.command()
|
|
@click.argument("username")
|
|
@click.option("--password", "-p", prompt=True, hide_input=True)
|
|
def login(username, password):
|
|
"""Login to get access token."""
|
|
try:
|
|
# Server expects form data, not JSON
|
|
resp = requests.post(
|
|
f"{get_l2_server()}/auth/login",
|
|
data={"username": username, "password": password}
|
|
)
|
|
if resp.status_code == 200:
|
|
# Check if we got a token back in a cookie
|
|
if "auth_token" in resp.cookies:
|
|
token = resp.cookies["auth_token"]
|
|
# Decode token to get username and expiry
|
|
import base64
|
|
try:
|
|
# JWT format: header.payload.signature
|
|
payload = token.split(".")[1]
|
|
# Add padding if needed
|
|
payload += "=" * (4 - len(payload) % 4)
|
|
decoded = json.loads(base64.urlsafe_b64decode(payload))
|
|
token_data = {
|
|
"access_token": token,
|
|
"username": decoded.get("username", username),
|
|
"expires_at": decoded.get("exp", "")
|
|
}
|
|
save_token(token_data)
|
|
click.echo(f"Logged in as {token_data['username']}")
|
|
if token_data.get("expires_at"):
|
|
click.echo(f"Token expires: {token_data['expires_at']}")
|
|
except Exception:
|
|
# If we can't decode, just save the token
|
|
save_token({"access_token": token, "username": username})
|
|
click.echo(f"Logged in as {username}")
|
|
else:
|
|
# HTML response - check for success/error
|
|
if "successful" in resp.text.lower():
|
|
click.echo(f"Login successful but no token received. Try logging in via web browser.")
|
|
elif "invalid" in resp.text.lower():
|
|
click.echo(f"Login failed: Invalid username or password", err=True)
|
|
sys.exit(1)
|
|
else:
|
|
click.echo(f"Login failed: {resp.text}", err=True)
|
|
sys.exit(1)
|
|
else:
|
|
click.echo(f"Login failed: {resp.text}", err=True)
|
|
sys.exit(1)
|
|
except requests.RequestException as e:
|
|
click.echo(f"Login failed: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
|
|
@cli.command()
|
|
@click.argument("username")
|
|
@click.option("--password", "-p", prompt=True, hide_input=True, confirmation_prompt=True)
|
|
@click.option("--email", "-e", default=None, help="Email (optional)")
|
|
def register(username, password, email):
|
|
"""Register a new account."""
|
|
try:
|
|
# Server expects form data, not JSON
|
|
form_data = {
|
|
"username": username,
|
|
"password": password,
|
|
"password2": password,
|
|
}
|
|
if email:
|
|
form_data["email"] = email
|
|
|
|
resp = requests.post(
|
|
f"{get_l2_server()}/auth/register",
|
|
data=form_data
|
|
)
|
|
if resp.status_code == 200:
|
|
# Check if we got a token back in a cookie
|
|
if "auth_token" in resp.cookies:
|
|
token = resp.cookies["auth_token"]
|
|
# Decode token to get username and expiry
|
|
import base64
|
|
try:
|
|
# JWT format: header.payload.signature
|
|
payload = token.split(".")[1]
|
|
# Add padding if needed
|
|
payload += "=" * (4 - len(payload) % 4)
|
|
decoded = json.loads(base64.urlsafe_b64decode(payload))
|
|
token_data = {
|
|
"access_token": token,
|
|
"username": decoded.get("username", username),
|
|
"expires_at": decoded.get("exp", "")
|
|
}
|
|
save_token(token_data)
|
|
click.echo(f"Registered and logged in as {token_data['username']}")
|
|
except Exception:
|
|
# If we can't decode, just save the token
|
|
save_token({"access_token": token, "username": username})
|
|
click.echo(f"Registered and logged in as {username}")
|
|
else:
|
|
# HTML response - registration successful
|
|
if "successful" in resp.text.lower():
|
|
click.echo(f"Registered as {username}. Please login to get a token.")
|
|
else:
|
|
click.echo(f"Registration failed: {resp.text}", err=True)
|
|
sys.exit(1)
|
|
else:
|
|
click.echo(f"Registration failed: {resp.text}", err=True)
|
|
sys.exit(1)
|
|
except requests.RequestException as e:
|
|
click.echo(f"Registration failed: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
|
|
@cli.command()
|
|
def logout():
|
|
"""Logout (clear saved token)."""
|
|
clear_token()
|
|
click.echo("Logged out")
|
|
|
|
|
|
@cli.command()
|
|
def whoami():
|
|
"""Show current logged-in user."""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in")
|
|
return
|
|
|
|
try:
|
|
resp = requests.get(
|
|
f"{get_l2_server()}/auth/me",
|
|
headers={"Authorization": f"Bearer {token_data['access_token']}"}
|
|
)
|
|
if resp.status_code == 200:
|
|
user = resp.json()
|
|
click.echo(f"Username: {user['username']}")
|
|
click.echo(f"Created: {user['created_at']}")
|
|
if user.get('email'):
|
|
click.echo(f"Email: {user['email']}")
|
|
else:
|
|
click.echo("Token invalid or expired. Please login again.", err=True)
|
|
clear_token()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Error: {e}", err=True)
|
|
|
|
|
|
# ============ Server Commands ============
|
|
|
|
@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. Requires login.
|
|
|
|
RECIPE: Effect/recipe to apply (e.g., dog, identity)
|
|
INPUT_HASH: Content hash of input asset
|
|
"""
|
|
# Check auth
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
# 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
|
|
|
|
try:
|
|
result = api_post("/runs", data, auth=True)
|
|
except requests.HTTPError as e:
|
|
if e.response.status_code == 401:
|
|
click.echo("Authentication failed. Please login again.", err=True)
|
|
sys.exit(1)
|
|
raise
|
|
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("delete-run")
|
|
@click.argument("run_id")
|
|
@click.option("--force", "-f", is_flag=True, help="Skip confirmation")
|
|
def delete_run(run_id, force):
|
|
"""Delete a run. Requires login.
|
|
|
|
RUN_ID: The run ID to delete
|
|
"""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
# Get run info first
|
|
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}", err=True)
|
|
sys.exit(1)
|
|
raise
|
|
|
|
if not force:
|
|
click.echo(f"Run: {run_id}")
|
|
click.echo(f"Status: {run['status']}")
|
|
click.echo(f"Recipe: {run['recipe']}")
|
|
if not click.confirm("Delete this run?"):
|
|
click.echo("Cancelled.")
|
|
return
|
|
|
|
try:
|
|
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
|
resp = requests.delete(f"{get_server()}/runs/{run_id}", headers=headers)
|
|
if resp.status_code == 400:
|
|
click.echo(f"Cannot delete: {resp.json().get('detail', 'Unknown error')}", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code == 403:
|
|
click.echo("Access denied", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code == 404:
|
|
click.echo(f"Run not found: {run_id}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to delete run: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
click.echo(f"Deleted run: {run_id}")
|
|
|
|
|
|
@cli.command("delete-cache")
|
|
@click.argument("content_hash")
|
|
@click.option("--force", "-f", is_flag=True, help="Skip confirmation")
|
|
def delete_cache(content_hash, force):
|
|
"""Delete a cached item. Requires login.
|
|
|
|
CONTENT_HASH: The content hash to delete
|
|
"""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
if not force:
|
|
click.echo(f"Content hash: {content_hash}")
|
|
if not click.confirm("Delete this cached item?"):
|
|
click.echo("Cancelled.")
|
|
return
|
|
|
|
try:
|
|
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
|
resp = requests.delete(f"{get_server()}/cache/{content_hash}", headers=headers)
|
|
if resp.status_code == 400:
|
|
click.echo(f"Cannot delete: {resp.json().get('detail', 'Unknown error')}", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code == 403:
|
|
click.echo("Access denied", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code == 404:
|
|
click.echo(f"Content not found: {content_hash}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to delete cache item: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
click.echo(f"Deleted: {content_hash}")
|
|
|
|
|
|
@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 <hash> -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 (local server only)."""
|
|
path = str(Path(filepath).resolve())
|
|
result = api_post("/cache/import", params={"path": path})
|
|
click.echo(f"Imported: {result['content_hash']}")
|
|
|
|
|
|
@cli.command()
|
|
@click.argument("filepath", type=click.Path(exists=True))
|
|
def upload(filepath):
|
|
"""Upload a file to cache (works with remote servers). Requires login."""
|
|
# Check auth
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
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()}/cache/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: {result['content_hash']}")
|
|
click.echo(f"Size: {result['size']} bytes")
|
|
except requests.RequestException as e:
|
|
click.echo(f"Upload failed: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
|
|
@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]}...")
|
|
|
|
|
|
@cli.command()
|
|
@click.argument("run_id")
|
|
@click.argument("output_name")
|
|
def publish(run_id, output_name):
|
|
"""Publish an L1 run to L2 (register ownership). Requires login.
|
|
|
|
RUN_ID: The L1 run ID to publish
|
|
OUTPUT_NAME: Name for the registered asset
|
|
"""
|
|
# Check auth
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
# Post to L2 server with auth, including which L1 server has the run
|
|
try:
|
|
resp = requests.post(
|
|
f"{get_l2_server()}/registry/record-run",
|
|
json={"run_id": run_id, "output_name": output_name, "l1_server": get_server()},
|
|
headers={"Authorization": f"Bearer {token_data['access_token']}"}
|
|
)
|
|
if resp.status_code == 401:
|
|
click.echo("Authentication failed. Please login again.", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to publish: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
result = resp.json()
|
|
click.echo(f"Published to L2!")
|
|
click.echo(f"Asset: {result['asset']['name']}")
|
|
click.echo(f"Hash: {result['asset']['content_hash']}")
|
|
click.echo(f"Activity: {result['activity']['activity_id']}")
|
|
|
|
|
|
# ============ Metadata Commands ============
|
|
|
|
@cli.command()
|
|
@click.argument("content_hash")
|
|
@click.option("--origin", type=click.Choice(["self", "external"]), help="Set origin type")
|
|
@click.option("--origin-url", help="Set external origin URL")
|
|
@click.option("--origin-note", help="Note about the origin")
|
|
@click.option("--description", "-d", help="Set description")
|
|
@click.option("--tags", "-t", help="Set tags (comma-separated)")
|
|
@click.option("--folder", "-f", help="Set folder path")
|
|
@click.option("--add-collection", help="Add to collection")
|
|
@click.option("--remove-collection", help="Remove from collection")
|
|
@click.option("--publish", "publish_name", help="Publish to L2 with given asset name")
|
|
@click.option("--publish-type", default="image", help="Asset type for publishing (image, video)")
|
|
@click.option("--republish", is_flag=True, help="Re-sync with L2 after metadata changes")
|
|
def meta(content_hash, origin, origin_url, origin_note, description, tags, folder, add_collection, remove_collection, publish_name, publish_type, republish):
|
|
"""View or update metadata for a cached item.
|
|
|
|
With no options, displays current metadata.
|
|
With options, updates the specified fields.
|
|
|
|
Use --publish <name> to publish to L2 (requires origin to be set).
|
|
Use --republish to sync metadata changes to L2.
|
|
"""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
|
|
|
# Handle publish action
|
|
if publish_name:
|
|
try:
|
|
resp = requests.post(
|
|
f"{get_server()}/cache/{content_hash}/publish",
|
|
json={"asset_name": publish_name, "asset_type": publish_type},
|
|
headers=headers
|
|
)
|
|
if resp.status_code == 400:
|
|
click.echo(f"Error: {resp.json().get('detail', 'Bad request')}", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code == 404:
|
|
click.echo(f"Content not found: {content_hash}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
result = resp.json()
|
|
click.echo(f"Published to L2!")
|
|
click.echo(f"Asset name: {result['asset_name']}")
|
|
click.echo(f"Activity: {result['l2_result']['activity']['activity_id']}")
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to publish: {e}", err=True)
|
|
sys.exit(1)
|
|
return
|
|
|
|
# Handle republish action
|
|
if republish:
|
|
try:
|
|
resp = requests.patch(
|
|
f"{get_server()}/cache/{content_hash}/republish",
|
|
headers=headers
|
|
)
|
|
if resp.status_code == 400:
|
|
click.echo(f"Error: {resp.json().get('detail', 'Bad request')}", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code == 404:
|
|
click.echo(f"Content not found: {content_hash}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
result = resp.json()
|
|
click.echo(f"Re-synced with L2!")
|
|
click.echo(f"Asset name: {result['asset_name']}")
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to republish: {e}", err=True)
|
|
sys.exit(1)
|
|
return
|
|
|
|
# If no update options, just display current metadata
|
|
has_updates = any([origin, origin_url, origin_note, description, tags, folder, add_collection, remove_collection])
|
|
|
|
if not has_updates:
|
|
# GET metadata
|
|
try:
|
|
resp = requests.get(f"{get_server()}/cache/{content_hash}/meta", headers=headers)
|
|
if resp.status_code == 404:
|
|
click.echo(f"Content not found: {content_hash}", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code == 403:
|
|
click.echo("Access denied", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
meta = resp.json()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to get metadata: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
click.echo(f"Content Hash: {content_hash}")
|
|
click.echo(f"Uploader: {meta.get('uploader', 'unknown')}")
|
|
click.echo(f"Uploaded: {meta.get('uploaded_at', 'unknown')}")
|
|
if meta.get("origin"):
|
|
origin_info = meta["origin"]
|
|
click.echo(f"Origin: {origin_info.get('type', 'unknown')}")
|
|
if origin_info.get("url"):
|
|
click.echo(f" URL: {origin_info['url']}")
|
|
if origin_info.get("note"):
|
|
click.echo(f" Note: {origin_info['note']}")
|
|
else:
|
|
click.echo("Origin: not set")
|
|
click.echo(f"Description: {meta.get('description', 'none')}")
|
|
click.echo(f"Tags: {', '.join(meta.get('tags', [])) or 'none'}")
|
|
click.echo(f"Folder: {meta.get('folder', '/')}")
|
|
click.echo(f"Collections: {', '.join(meta.get('collections', [])) or 'none'}")
|
|
if meta.get("published"):
|
|
pub = meta["published"]
|
|
click.echo(f"Published: {pub.get('asset_name')} ({pub.get('published_at')})")
|
|
return
|
|
|
|
# Build update payload
|
|
update = {}
|
|
|
|
if origin or origin_url or origin_note:
|
|
# Get current origin first
|
|
try:
|
|
resp = requests.get(f"{get_server()}/cache/{content_hash}/meta", headers=headers)
|
|
resp.raise_for_status()
|
|
current = resp.json()
|
|
current_origin = current.get("origin", {})
|
|
except:
|
|
current_origin = {}
|
|
|
|
update["origin"] = {
|
|
"type": origin or current_origin.get("type", "self"),
|
|
"url": origin_url if origin_url is not None else current_origin.get("url"),
|
|
"note": origin_note if origin_note is not None else current_origin.get("note")
|
|
}
|
|
|
|
if description is not None:
|
|
update["description"] = description
|
|
|
|
if tags is not None:
|
|
update["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
|
|
|
if folder is not None:
|
|
update["folder"] = folder
|
|
|
|
if add_collection or remove_collection:
|
|
# Get current collections
|
|
try:
|
|
resp = requests.get(f"{get_server()}/cache/{content_hash}/meta", headers=headers)
|
|
resp.raise_for_status()
|
|
current = resp.json()
|
|
collections = set(current.get("collections", []))
|
|
except:
|
|
collections = set()
|
|
|
|
if add_collection:
|
|
collections.add(add_collection)
|
|
if remove_collection and remove_collection in collections:
|
|
collections.remove(remove_collection)
|
|
update["collections"] = list(collections)
|
|
|
|
# PATCH metadata
|
|
try:
|
|
resp = requests.patch(
|
|
f"{get_server()}/cache/{content_hash}/meta",
|
|
json=update,
|
|
headers=headers
|
|
)
|
|
if resp.status_code == 404:
|
|
click.echo(f"Content not found: {content_hash}", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code == 400:
|
|
click.echo(f"Error: {resp.json().get('detail', 'Bad request')}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to update metadata: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
click.echo("Metadata updated.")
|
|
|
|
|
|
# ============ Folder Commands ============
|
|
|
|
@cli.group()
|
|
def folder():
|
|
"""Manage folders for organizing cached items."""
|
|
pass
|
|
|
|
|
|
@folder.command("list")
|
|
def folder_list():
|
|
"""List all folders."""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
resp = requests.get(
|
|
f"{get_server()}/user/folders",
|
|
headers={"Authorization": f"Bearer {token_data['access_token']}"}
|
|
)
|
|
resp.raise_for_status()
|
|
folders = resp.json()["folders"]
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to list folders: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
click.echo("Folders:")
|
|
for f in folders:
|
|
click.echo(f" {f}")
|
|
|
|
|
|
@folder.command("create")
|
|
@click.argument("path")
|
|
def folder_create(path):
|
|
"""Create a new folder."""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
resp = requests.post(
|
|
f"{get_server()}/user/folders",
|
|
params={"folder_path": path},
|
|
headers={"Authorization": f"Bearer {token_data['access_token']}"}
|
|
)
|
|
if resp.status_code == 400:
|
|
click.echo(f"Error: {resp.json().get('detail', 'Bad request')}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to create folder: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
click.echo(f"Created folder: {path}")
|
|
|
|
|
|
@folder.command("delete")
|
|
@click.argument("path")
|
|
def folder_delete(path):
|
|
"""Delete a folder (must be empty)."""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
resp = requests.delete(
|
|
f"{get_server()}/user/folders",
|
|
params={"folder_path": path},
|
|
headers={"Authorization": f"Bearer {token_data['access_token']}"}
|
|
)
|
|
if resp.status_code == 400:
|
|
click.echo(f"Error: {resp.json().get('detail', 'Bad request')}", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code == 404:
|
|
click.echo(f"Folder not found: {path}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to delete folder: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
click.echo(f"Deleted folder: {path}")
|
|
|
|
|
|
# ============ Collection Commands ============
|
|
|
|
@cli.group()
|
|
def collection():
|
|
"""Manage collections for organizing cached items."""
|
|
pass
|
|
|
|
|
|
@collection.command("list")
|
|
def collection_list():
|
|
"""List all collections."""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
resp = requests.get(
|
|
f"{get_server()}/user/collections",
|
|
headers={"Authorization": f"Bearer {token_data['access_token']}"}
|
|
)
|
|
resp.raise_for_status()
|
|
collections = resp.json()["collections"]
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to list collections: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
click.echo("Collections:")
|
|
for c in collections:
|
|
click.echo(f" {c['name']} (created: {c['created_at'][:10]})")
|
|
|
|
|
|
@collection.command("create")
|
|
@click.argument("name")
|
|
def collection_create(name):
|
|
"""Create a new collection."""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
resp = requests.post(
|
|
f"{get_server()}/user/collections",
|
|
params={"name": name},
|
|
headers={"Authorization": f"Bearer {token_data['access_token']}"}
|
|
)
|
|
if resp.status_code == 400:
|
|
click.echo(f"Error: {resp.json().get('detail', 'Bad request')}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to create collection: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
click.echo(f"Created collection: {name}")
|
|
|
|
|
|
@collection.command("delete")
|
|
@click.argument("name")
|
|
def collection_delete(name):
|
|
"""Delete a collection."""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
resp = requests.delete(
|
|
f"{get_server()}/user/collections",
|
|
params={"name": name},
|
|
headers={"Authorization": f"Bearer {token_data['access_token']}"}
|
|
)
|
|
if resp.status_code == 404:
|
|
click.echo(f"Collection not found: {name}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to delete collection: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
click.echo(f"Deleted collection: {name}")
|
|
|
|
|
|
# ============ Recipe Commands ============
|
|
|
|
def _is_sexp_file(filepath: str, content: str) -> bool:
|
|
"""Detect if file is S-expression format."""
|
|
# Check extension first
|
|
if filepath.endswith('.sexp'):
|
|
return True
|
|
# Check content - skip comments and whitespace
|
|
for line in content.split('\n'):
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith(';'):
|
|
continue
|
|
return stripped.startswith('(')
|
|
return False
|
|
|
|
|
|
@cli.command("upload-recipe")
|
|
@click.argument("filepath", type=click.Path(exists=True))
|
|
def upload_recipe(filepath):
|
|
"""Upload a recipe file (YAML or S-expression). Requires login."""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
# Read content
|
|
with open(filepath) as f:
|
|
content = f.read()
|
|
|
|
# Detect format and validate
|
|
is_sexp = _is_sexp_file(filepath, content)
|
|
|
|
if is_sexp:
|
|
# S-expression - basic syntax check (starts with paren after comments)
|
|
# Full validation happens on server
|
|
click.echo("Detected S-expression format")
|
|
else:
|
|
# Validate YAML locally
|
|
try:
|
|
recipe = yaml.safe_load(content)
|
|
except yaml.YAMLError as e:
|
|
click.echo(f"Invalid YAML: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
# Check required fields for YAML
|
|
if not recipe.get("name"):
|
|
click.echo("Recipe 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()}/recipes/upload", files=files, headers=headers)
|
|
if resp.status_code == 401:
|
|
click.echo("Authentication failed. Please login again.", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code >= 400:
|
|
click.echo(f"Error response: {resp.text}", err=True)
|
|
resp.raise_for_status()
|
|
result = resp.json()
|
|
|
|
click.echo(f"Uploaded recipe: {result['name']} v{result.get('version', '1.0')}")
|
|
click.echo(f"Recipe ID: {result['recipe_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("upload-effect")
|
|
@click.argument("filepath", type=click.Path(exists=True))
|
|
def upload_effect(filepath):
|
|
"""Upload an effect file to IPFS. Requires login.
|
|
|
|
Effects are Python files with PEP 723 dependencies and @-tag metadata.
|
|
Returns the IPFS CID for use in recipes.
|
|
"""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
# Check it's a Python file
|
|
if not filepath.endswith(".py"):
|
|
click.echo("Effect must be a Python file (.py)", 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()}/effects/upload", files=files, headers=headers)
|
|
if resp.status_code == 401:
|
|
click.echo("Authentication failed. Please login again.", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code >= 400:
|
|
click.echo(f"Error response: {resp.text}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
result = resp.json()
|
|
|
|
click.echo(f"Uploaded effect: {result['name']} v{result.get('version', '1.0.0')}")
|
|
click.echo(f"CID: {result['cid']}")
|
|
click.echo(f"Temporal: {result.get('temporal', False)}")
|
|
if result.get('params'):
|
|
click.echo(f"Parameters: {', '.join(p['name'] for p in result['params'])}")
|
|
if result.get('dependencies'):
|
|
click.echo(f"Dependencies: {', '.join(result['dependencies'])}")
|
|
click.echo()
|
|
click.echo("Use in recipes:")
|
|
click.echo(f' (effect {result["name"]} :cid "{result["cid"]}")')
|
|
except requests.RequestException as e:
|
|
click.echo(f"Upload failed: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
|
|
@cli.command("effects")
|
|
@click.option("--limit", "-l", default=20, help="Max effects to show")
|
|
def list_effects(limit):
|
|
"""List uploaded effects."""
|
|
try:
|
|
headers = {}
|
|
token_data = load_token()
|
|
if token_data.get("access_token"):
|
|
headers["Authorization"] = f"Bearer {token_data['access_token']}"
|
|
|
|
resp = requests.get(f"{get_server()}/effects", headers=headers)
|
|
resp.raise_for_status()
|
|
result = resp.json()
|
|
|
|
effects = result.get("effects", [])[:limit]
|
|
if not effects:
|
|
click.echo("No effects found")
|
|
return
|
|
|
|
click.echo(f"Effects ({len(effects)}):\n")
|
|
for effect in effects:
|
|
meta = effect.get("meta", {})
|
|
click.echo(f" {meta.get('name', 'unknown')} v{meta.get('version', '?')}")
|
|
click.echo(f" Hash: {effect['content_hash'][:32]}...")
|
|
click.echo(f" Temporal: {meta.get('temporal', False)}")
|
|
if meta.get('params'):
|
|
click.echo(f" Params: {', '.join(p['name'] for p in meta['params'])}")
|
|
click.echo()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to list effects: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
|
|
@cli.command("recipes")
|
|
@click.option("--limit", "-l", default=10, help="Max recipes to show")
|
|
def list_recipes(limit):
|
|
"""List uploaded recipes."""
|
|
try:
|
|
resp = requests.get(f"{get_server()}/recipes")
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to list recipes: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
recipes = data.get("recipes", [])
|
|
if not recipes:
|
|
click.echo("No recipes found.")
|
|
return
|
|
|
|
click.echo(f"{'Name':<20} {'Version':<8} {'Variables':<10} {'Recipe ID':<24}")
|
|
click.echo("-" * 70)
|
|
|
|
for recipe in recipes[:limit]:
|
|
recipe_id = recipe["recipe_id"][:20] + "..."
|
|
var_count = len(recipe.get("variable_inputs", []))
|
|
click.echo(f"{recipe['name']:<20} {recipe['version']:<8} {var_count:<10} {recipe_id}")
|
|
|
|
|
|
@cli.command("recipe")
|
|
@click.argument("recipe_id")
|
|
def show_recipe(recipe_id):
|
|
"""Show details of a recipe."""
|
|
try:
|
|
resp = requests.get(f"{get_server()}/recipes/{recipe_id}")
|
|
if resp.status_code == 404:
|
|
click.echo(f"Recipe not found: {recipe_id}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
recipe = resp.json()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to get recipe: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
click.echo(f"Name: {recipe['name']}")
|
|
click.echo(f"Version: {recipe['version']}")
|
|
click.echo(f"Description: {recipe.get('description', 'N/A')}")
|
|
click.echo(f"Recipe ID: {recipe['recipe_id']}")
|
|
click.echo(f"Owner: {recipe.get('owner', 'N/A')}")
|
|
click.echo(f"Uploaded: {recipe['uploaded_at']}")
|
|
|
|
if recipe.get("variable_inputs"):
|
|
click.echo("\nVariable Inputs:")
|
|
for inp in recipe["variable_inputs"]:
|
|
req = "*" if inp.get("required", True) else ""
|
|
click.echo(f" - {inp['name']}{req}: {inp.get('description', 'No description')}")
|
|
|
|
if recipe.get("fixed_inputs"):
|
|
click.echo("\nFixed Inputs:")
|
|
for inp in recipe["fixed_inputs"]:
|
|
click.echo(f" - {inp['asset']}: {inp['content_hash'][:16]}...")
|
|
|
|
|
|
@cli.command("run-recipe")
|
|
@click.argument("recipe_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_recipe(recipe_id, inputs, wait):
|
|
"""Run a recipe with variable inputs. Requires login.
|
|
|
|
RECIPE_ID: The recipe ID (content hash)
|
|
|
|
Example: artdag run-recipe 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 <username>", 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()}/recipes/{recipe_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"Recipe not found: {recipe_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']}")
|
|
if result.get('recipe'):
|
|
click.echo(f"Recipe: {result['recipe']}")
|
|
click.echo(f"Status: {result.get('status', 'pending')}")
|
|
|
|
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-recipe")
|
|
@click.argument("recipe_id")
|
|
@click.option("--force", "-f", is_flag=True, help="Skip confirmation")
|
|
def delete_recipe(recipe_id, force):
|
|
"""Delete a recipe. Requires login."""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
if not force:
|
|
if not click.confirm(f"Delete recipe {recipe_id[:16]}...?"):
|
|
click.echo("Cancelled.")
|
|
return
|
|
|
|
try:
|
|
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
|
resp = requests.delete(f"{get_server()}/recipes/{recipe_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"Recipe not found: {recipe_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 recipe: {recipe_id[:16]}...")
|
|
|
|
|
|
# ============ v2 API Commands (3-Phase Execution) ============
|
|
|
|
@cli.command("plan")
|
|
@click.argument("recipe_file", type=click.Path(exists=True))
|
|
@click.option("--input", "-i", "inputs", multiple=True, help="Input as name:content_hash")
|
|
@click.option("--features", "-f", multiple=True, help="Features to extract (default: beats, energy)")
|
|
@click.option("--output", "-o", type=click.Path(), help="Save plan JSON to file")
|
|
def generate_plan(recipe_file, inputs, features, output):
|
|
"""Generate an execution plan from a recipe YAML. Requires login.
|
|
|
|
Preview what will be executed without actually running it.
|
|
|
|
RECIPE_FILE: Path to recipe YAML file
|
|
|
|
Example: artdag plan recipe.yaml -i source_video:abc123
|
|
"""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
# Read recipe YAML
|
|
with open(recipe_file) as f:
|
|
recipe_yaml = f.read()
|
|
|
|
# Parse inputs
|
|
input_hashes = {}
|
|
for inp in inputs:
|
|
if ":" not in inp:
|
|
click.echo(f"Invalid input format: {inp} (expected name:content_hash)", err=True)
|
|
sys.exit(1)
|
|
name, content_hash = inp.split(":", 1)
|
|
input_hashes[name] = content_hash
|
|
|
|
# Build request
|
|
request_data = {
|
|
"recipe_yaml": recipe_yaml,
|
|
"input_hashes": input_hashes,
|
|
}
|
|
if features:
|
|
request_data["features"] = list(features)
|
|
|
|
# Submit to API
|
|
try:
|
|
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
|
resp = requests.post(
|
|
f"{get_server()}/api/v2/plan",
|
|
json=request_data,
|
|
headers=headers
|
|
)
|
|
if resp.status_code == 401:
|
|
click.echo("Authentication failed. Please login again.", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code == 400:
|
|
click.echo(f"Error: {resp.json().get('detail', 'Bad request')}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
result = resp.json()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Plan generation failed: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
# Display results
|
|
click.echo(f"Recipe: {result['recipe']}")
|
|
click.echo(f"Plan ID: {result['plan_id'][:16]}...")
|
|
click.echo(f"Total steps: {result['total_steps']}")
|
|
click.echo(f"Cached: {result['cached_steps']}")
|
|
click.echo(f"Pending: {result['pending_steps']}")
|
|
|
|
if result.get("steps"):
|
|
click.echo("\nSteps:")
|
|
for step in result["steps"]:
|
|
status = "✓ cached" if step["cached"] else "○ pending"
|
|
click.echo(f" L{step['level']} {step['step_id']:<20} {step['node_type']:<10} {status}")
|
|
|
|
# Save plan JSON if requested
|
|
if output:
|
|
with open(output, "w") as f:
|
|
f.write(result["plan_json"])
|
|
click.echo(f"\nPlan saved to: {output}")
|
|
elif result.get("plan_json"):
|
|
click.echo(f"\nUse --output to save the plan JSON for later execution.")
|
|
|
|
|
|
@cli.command("execute-plan")
|
|
@click.argument("plan_file", type=click.Path(exists=True))
|
|
@click.option("--wait", "-w", is_flag=True, help="Wait for completion")
|
|
def execute_plan(plan_file, wait):
|
|
"""Execute a pre-generated execution plan. Requires login.
|
|
|
|
PLAN_FILE: Path to plan JSON file (from 'artdag plan --output')
|
|
|
|
Example: artdag execute-plan plan.json --wait
|
|
"""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
# Read plan JSON
|
|
with open(plan_file) as f:
|
|
plan_json = f.read()
|
|
|
|
# Submit to API
|
|
try:
|
|
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
|
resp = requests.post(
|
|
f"{get_server()}/api/v2/execute",
|
|
json={"plan_json": plan_json},
|
|
headers=headers
|
|
)
|
|
if resp.status_code == 401:
|
|
click.echo("Authentication failed. Please login again.", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code == 400:
|
|
click.echo(f"Error: {resp.json().get('detail', 'Bad request')}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
result = resp.json()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Execution failed: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
run_id = result["run_id"]
|
|
click.echo(f"Run started: {run_id}")
|
|
click.echo(f"Status: {result['status']}")
|
|
|
|
if wait:
|
|
_wait_for_v2_run(token_data, run_id)
|
|
|
|
|
|
@cli.command("run-v2")
|
|
@click.argument("recipe_file", type=click.Path(exists=True))
|
|
@click.option("--input", "-i", "inputs", multiple=True, help="Input as name:content_hash")
|
|
@click.option("--features", "-f", multiple=True, help="Features to extract (default: beats, energy)")
|
|
@click.option("--wait", "-w", is_flag=True, help="Wait for completion")
|
|
def run_recipe_v2(recipe_file, inputs, features, wait):
|
|
"""Run a recipe through 3-phase execution. Requires login.
|
|
|
|
Runs the full pipeline: Analyze → Plan → Execute
|
|
|
|
RECIPE_FILE: Path to recipe YAML file
|
|
|
|
Example: artdag run-v2 recipe.yaml -i source_video:abc123 --wait
|
|
"""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
# Read recipe YAML
|
|
with open(recipe_file) as f:
|
|
recipe_yaml = f.read()
|
|
|
|
# Parse recipe name for display
|
|
try:
|
|
recipe_data = yaml.safe_load(recipe_yaml)
|
|
recipe_name = recipe_data.get("name", "unknown")
|
|
except Exception:
|
|
recipe_name = "unknown"
|
|
|
|
# Parse inputs
|
|
input_hashes = {}
|
|
for inp in inputs:
|
|
if ":" not in inp:
|
|
click.echo(f"Invalid input format: {inp} (expected name:content_hash)", err=True)
|
|
sys.exit(1)
|
|
name, content_hash = inp.split(":", 1)
|
|
input_hashes[name] = content_hash
|
|
|
|
# Build request
|
|
request_data = {
|
|
"recipe_yaml": recipe_yaml,
|
|
"input_hashes": input_hashes,
|
|
}
|
|
if features:
|
|
request_data["features"] = list(features)
|
|
|
|
# Submit to API
|
|
click.echo(f"Running recipe: {recipe_name}")
|
|
click.echo(f"Inputs: {len(input_hashes)}")
|
|
|
|
try:
|
|
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
|
resp = requests.post(
|
|
f"{get_server()}/api/v2/run-recipe",
|
|
json=request_data,
|
|
headers=headers
|
|
)
|
|
if resp.status_code == 401:
|
|
click.echo("Authentication failed. Please login again.", err=True)
|
|
sys.exit(1)
|
|
if resp.status_code == 400:
|
|
click.echo(f"Error: {resp.json().get('detail', 'Bad request')}", 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)
|
|
|
|
run_id = result["run_id"]
|
|
click.echo(f"Run ID: {run_id}")
|
|
click.echo(f"Status: {result['status']}")
|
|
|
|
if result.get("output_hash"):
|
|
click.echo(f"Output: {result['output_hash']}")
|
|
if result.get("output_ipfs_cid"):
|
|
click.echo(f"IPFS CID: {result['output_ipfs_cid']}")
|
|
return
|
|
|
|
if wait:
|
|
_wait_for_v2_run(token_data, run_id)
|
|
|
|
|
|
def _wait_for_v2_run(token_data: dict, run_id: str):
|
|
"""Poll v2 run status until completion."""
|
|
click.echo("Waiting for completion...")
|
|
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
|
|
|
while True:
|
|
time.sleep(2)
|
|
try:
|
|
resp = requests.get(
|
|
f"{get_server()}/api/v2/run/{run_id}",
|
|
headers=headers
|
|
)
|
|
resp.raise_for_status()
|
|
run = resp.json()
|
|
except requests.RequestException as e:
|
|
click.echo(f".", nl=False)
|
|
continue
|
|
|
|
status = run.get("status", "unknown")
|
|
|
|
if status == "completed":
|
|
click.echo(f"\nCompleted!")
|
|
if run.get("output_hash"):
|
|
click.echo(f"Output: {run['output_hash']}")
|
|
if run.get("output_ipfs_cid"):
|
|
click.echo(f"IPFS CID: {run['output_ipfs_cid']}")
|
|
if run.get("cached"):
|
|
click.echo(f"Steps cached: {run['cached']}")
|
|
if run.get("executed"):
|
|
click.echo(f"Steps executed: {run['executed']}")
|
|
break
|
|
elif status == "failed":
|
|
click.echo(f"\nFailed: {run.get('error', 'Unknown error')}", err=True)
|
|
sys.exit(1)
|
|
else:
|
|
click.echo(".", nl=False)
|
|
|
|
|
|
@cli.command("run-status")
|
|
@click.argument("run_id")
|
|
def run_status_v2(run_id):
|
|
"""Get status of a v2 run. Requires login.
|
|
|
|
RUN_ID: The run ID from run-v2 or execute-plan
|
|
"""
|
|
token_data = load_token()
|
|
if not token_data.get("access_token"):
|
|
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
|
resp = requests.get(
|
|
f"{get_server()}/api/v2/run/{run_id}",
|
|
headers=headers
|
|
)
|
|
if resp.status_code == 404:
|
|
click.echo(f"Run not found: {run_id}", err=True)
|
|
sys.exit(1)
|
|
resp.raise_for_status()
|
|
run = resp.json()
|
|
except requests.RequestException as e:
|
|
click.echo(f"Failed to get status: {e}", err=True)
|
|
sys.exit(1)
|
|
|
|
click.echo(f"Run ID: {run_id}")
|
|
click.echo(f"Status: {run['status']}")
|
|
|
|
if run.get("recipe"):
|
|
click.echo(f"Recipe: {run['recipe']}")
|
|
if run.get("plan_id"):
|
|
click.echo(f"Plan ID: {run['plan_id'][:16]}...")
|
|
if run.get("output_hash"):
|
|
click.echo(f"Output: {run['output_hash']}")
|
|
if run.get("output_ipfs_cid"):
|
|
click.echo(f"IPFS CID: {run['output_ipfs_cid']}")
|
|
if run.get("cached") is not None:
|
|
click.echo(f"Cached: {run['cached']}")
|
|
if run.get("executed") is not None:
|
|
click.echo(f"Executed: {run['executed']}")
|
|
if run.get("error"):
|
|
click.echo(f"Error: {run['error']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
cli()
|