feat: add cache metadata, folders, and collections (L1 + CLI)

L1 Server:
- Extended save_cache_meta to support updates
- Added GET/PATCH /cache/{hash}/meta endpoints
- Added user data storage for folders/collections
- Added folder CRUD endpoints (/user/folders)
- Added collection CRUD endpoints (/user/collections)
- Cache list now supports filtering by folder/collection/tag

CLI:
- Added `meta` command to view/update cache item metadata
- Added `folder` command group (list, create, delete)
- Added `collection` command group (list, create, delete)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-07 18:54:40 +00:00
parent 9eca507d84
commit b82843f1c0

301
artdag.py
View File

@@ -433,5 +433,306 @@ def publish(run_id, output_name):
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")
def meta(content_hash, origin, origin_url, origin_note, description, tags, folder, add_collection, remove_collection):
"""View or update metadata for a cached item.
With no options, displays current metadata.
With options, updates the specified fields.
"""
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']}"}
# 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}")
if __name__ == "__main__":
cli()