muapi-cli 0.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,746 @@
1
+ """muapi workflow — list, create, run, and visualize AI workflows."""
2
+ import json
3
+ import time
4
+ from typing import Optional
5
+
6
+ import httpx
7
+ import typer
8
+
9
+ from .. import exitcodes
10
+ from ..config import BASE_URL, get_api_key
11
+ from ..utils import console, error_exit, out
12
+
13
+ app = typer.Typer(help="Build, run, and visualize multi-step AI workflows.")
14
+
15
+ # Workflow router is at /workflow, not /api/v1
16
+ _WORKFLOW_BASE = BASE_URL.replace("/api/v1", "") + "/workflow"
17
+ _POLL_INTERVAL = 4
18
+ _MAX_WAIT = 600
19
+
20
+
21
+ def _headers() -> dict:
22
+ key = get_api_key()
23
+ if not key:
24
+ error_exit("No API key configured. Run: muapi auth configure", exitcodes.AUTH_ERROR)
25
+ return {"x-api-key": key, "Content-Type": "application/json"}
26
+
27
+
28
+ def _get(path: str) -> dict:
29
+ resp = httpx.get(f"{_WORKFLOW_BASE}/{path.lstrip('/')}", headers=_headers(), timeout=30.0)
30
+ if resp.status_code >= 400:
31
+ raise httpx.HTTPStatusError(resp.text, request=resp.request, response=resp)
32
+ return resp.json()
33
+
34
+
35
+ def _post(path: str, body: dict) -> dict:
36
+ resp = httpx.post(f"{_WORKFLOW_BASE}/{path.lstrip('/')}", json=body, headers=_headers(), timeout=60.0)
37
+ if resp.status_code >= 400:
38
+ raise httpx.HTTPStatusError(resp.text, request=resp.request, response=resp)
39
+ return resp.json()
40
+
41
+
42
+ # ── ASCII visualization ────────────────────────────────────────────────────────
43
+
44
+ def _visualize(workflow: dict) -> None:
45
+ """Render a workflow node graph in the terminal using Rich."""
46
+ from rich.panel import Panel
47
+ from rich.columns import Columns
48
+ from rich.text import Text
49
+
50
+ nodes_raw = workflow.get("nodes") or workflow.get("data", {}).get("nodes") or []
51
+ if not nodes_raw:
52
+ console.print("[dim]No nodes found in workflow.[/dim]")
53
+ return
54
+
55
+ # Build adjacency: {node_id: [downstream_ids]}
56
+ id_to_node = {n["id"]: n for n in nodes_raw}
57
+ downstream: dict[str, list] = {n["id"]: [] for n in nodes_raw}
58
+ upstream: dict[str, list] = {n["id"]: n.get("inputs", []) for n in nodes_raw}
59
+
60
+ for node in nodes_raw:
61
+ for parent_id in node.get("inputs", []):
62
+ if parent_id in downstream:
63
+ downstream[parent_id].append(node["id"])
64
+
65
+ # Topological sort (Kahn's algorithm)
66
+ in_degree = {n["id"]: len(n.get("inputs", [])) for n in nodes_raw}
67
+ queue = [nid for nid, deg in in_degree.items() if deg == 0]
68
+ levels: list[list[str]] = []
69
+ visited = set()
70
+
71
+ while queue:
72
+ levels.append(queue[:])
73
+ next_q = []
74
+ for nid in queue:
75
+ visited.add(nid)
76
+ for child in downstream.get(nid, []):
77
+ in_degree[child] -= 1
78
+ if in_degree[child] == 0:
79
+ next_q.append(child)
80
+ queue = next_q
81
+
82
+ # Render level by level
83
+ console.print(f"\n[bold]Workflow:[/bold] {workflow.get('name', workflow.get('id', ''))}")
84
+ console.print(f"[dim]{len(nodes_raw)} nodes[/dim]\n")
85
+
86
+ for level_idx, level in enumerate(levels):
87
+ panels = []
88
+ for nid in level:
89
+ node = id_to_node[nid]
90
+ ntype = node.get("type", "?")
91
+ params = node.get("params", {})
92
+ # Show first 2 non-empty params for brevity
93
+ param_lines = [f"[dim]{k}[/dim]: {str(v)[:40]}"
94
+ for k, v in params.items()
95
+ if v and k not in ("webhook_url",)][:2]
96
+ body = Text()
97
+ body.append(f"[{ntype}]\n", style="bold cyan")
98
+ body.append(f"id: {nid}\n", style="dim")
99
+ for pl in param_lines:
100
+ body.append(pl + "\n")
101
+ panels.append(Panel(body, expand=False, border_style="blue"))
102
+
103
+ console.print(Columns(panels, equal=False, expand=False))
104
+
105
+ # Draw connectors between levels
106
+ if level_idx < len(levels) - 1:
107
+ # Show which nodes connect forward
108
+ arrows = []
109
+ for nid in level:
110
+ children = downstream.get(nid, [])
111
+ if children:
112
+ arrows.append(f"[dim]{nid}[/dim] [bold]──►[/bold] {', '.join(children)}")
113
+ if arrows:
114
+ for a in arrows:
115
+ console.print(f" {a}")
116
+ console.print()
117
+
118
+
119
+ # ── Commands ──────────────────────────────────────────────────────────────────
120
+
121
+ @app.command("list")
122
+ def list_workflows(
123
+ limit: Optional[int] = typer.Option(None, "--limit", help="Max workflows to show"),
124
+ output_json: bool = typer.Option(False, "--output-json", "-j"),
125
+ ):
126
+ """List all your saved workflows."""
127
+ try:
128
+ data = _get("get-workflow-defs")
129
+ except httpx.HTTPStatusError as e:
130
+ error_exit(str(e), exitcodes.ERROR)
131
+
132
+ workflows = data if isinstance(data, list) else data.get("workflows", [data])
133
+ if limit:
134
+ workflows = workflows[:limit]
135
+
136
+ if output_json:
137
+ out.print_json(json.dumps(workflows))
138
+ return
139
+
140
+ if not workflows:
141
+ console.print("[dim]No workflows found.[/dim]")
142
+ return
143
+
144
+ table = Table(show_header=True, header_style="bold")
145
+ table.add_column("ID", style="dim", no_wrap=True)
146
+ table.add_column("Name", style="cyan")
147
+ table.add_column("Category", style="green")
148
+ table.add_column("Description", style="italic")
149
+ table.add_column("Nodes", justify="right")
150
+ table.add_column("Created")
151
+ for w in workflows:
152
+ nodes = w.get("nodes") or w.get("data", {}).get("nodes") or []
153
+ desc = w.get("description") or ""
154
+ if len(desc) > 50: desc = desc[:47] + "..."
155
+ table.add_row(
156
+ str(w.get("id", "")),
157
+ w.get("name", "(unnamed)"),
158
+ w.get("category", "General"),
159
+ desc,
160
+ str(len(nodes)),
161
+ str(w.get("created_at", ""))[:10],
162
+ )
163
+ console.print(table)
164
+
165
+
166
+ @app.command("discover")
167
+ def discover_workflows(
168
+ query: Optional[str] = typer.Argument(None, help="Optional search intent (ignored locally, used for LLM context)"),
169
+ limit: int = typer.Option(50, "--limit", help="Max matches to return for the LLM to analyze"),
170
+ output_json: bool = typer.Option(False, "--output-json", "-j"),
171
+ ):
172
+ """
173
+ List workflows with their descriptions so an AI agent can find the best match.
174
+ """
175
+ try:
176
+ data = _get("get-workflow-defs")
177
+ except httpx.HTTPStatusError as e:
178
+ error_exit(str(e), exitcodes.ERROR)
179
+
180
+ workflows = data if isinstance(data, list) else data.get("workflows", [data])
181
+
182
+ # We rely on the calling LLM agent to do the semantic matching, so we return all available ones.
183
+ matches = workflows
184
+
185
+ if output_json:
186
+ out.print_json(json.dumps(matches[:limit]))
187
+ return
188
+
189
+ if not matches:
190
+ console.print(f"[dim]No workflows found in your account.[/dim]")
191
+ return
192
+
193
+ if query:
194
+ console.print(f"[bold]Evaluating workflows for:[/bold] '{query}'")
195
+ else:
196
+ console.print(f"[bold]Workflows available for discovery:[/bold]")
197
+
198
+ from rich.table import Table
199
+ table = Table(show_header=True, header_style="bold")
200
+ table.add_column("ID", style="dim")
201
+ table.add_column("Name", style="cyan")
202
+ table.add_column("Category", style="green")
203
+ table.add_column("Description", style="italic")
204
+ for w in matches[:limit]:
205
+ desc = w.get("description") or ""
206
+ if len(desc) > 80: desc = desc[:77] + "..."
207
+ table.add_row(
208
+ str(w.get("id")),
209
+ w.get("name"),
210
+ w.get("category", "General"),
211
+ desc
212
+ )
213
+ console.print(table)
214
+
215
+ if matches:
216
+ console.print(f"\n[green]Best Match ID:[/green] [bold]{matches[0].get('id')}[/bold]")
217
+
218
+
219
+ @app.command("templates")
220
+ def list_templates(
221
+ output_json: bool = typer.Option(False, "--output-json", "-j"),
222
+ ):
223
+ """List available workflow templates."""
224
+ try:
225
+ data = _get("get-template-workflows")
226
+ except httpx.HTTPStatusError as e:
227
+ error_exit(str(e), exitcodes.ERROR)
228
+
229
+ templates = data if isinstance(data, list) else data.get("workflows", [])
230
+ if output_json:
231
+ out.print_json(json.dumps(templates))
232
+ return
233
+
234
+ from rich.table import Table
235
+ table = Table(show_header=True, header_style="bold")
236
+ table.add_column("ID", style="dim", no_wrap=True)
237
+ table.add_column("Name")
238
+ table.add_column("Nodes", justify="right")
239
+ for t in templates:
240
+ nodes = t.get("nodes") or t.get("data", {}).get("nodes") or []
241
+ table.add_row(str(t.get("id", "")), t.get("name", ""), str(len(nodes)))
242
+ console.print(table)
243
+
244
+
245
+ @app.command("get")
246
+ def get_workflow(
247
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
248
+ output_json: bool = typer.Option(False, "--output-json", "-j"),
249
+ no_viz: bool = typer.Option(False, "--no-viz", help="Skip ASCII visualization"),
250
+ ):
251
+ """Get a workflow definition and visualize its node graph."""
252
+ try:
253
+ data = _get(f"get-workflow-def/{workflow_id}")
254
+ except httpx.HTTPStatusError as e:
255
+ error_exit(str(e), exitcodes.ERROR)
256
+
257
+ if output_json:
258
+ out.print_json(json.dumps(data))
259
+ return
260
+
261
+ if not no_viz:
262
+ _visualize(data)
263
+
264
+ # Also show API inputs
265
+ try:
266
+ inputs = _get(f"{workflow_id}/api-inputs")
267
+ if inputs:
268
+ console.print("\n[bold]API Inputs:[/bold]")
269
+ from rich.table import Table
270
+ t = Table(show_header=True, header_style="bold")
271
+ t.add_column("Node ID")
272
+ t.add_column("Parameter")
273
+ t.add_column("Type")
274
+ t.add_column("Required")
275
+ for node_id, params in (inputs.get("inputs") or {}).items():
276
+ for param, meta in (params or {}).items():
277
+ t.add_row(node_id, param,
278
+ meta.get("type", "any"),
279
+ "[green]yes[/green]" if meta.get("required") else "no")
280
+ console.print(t)
281
+ except Exception:
282
+ pass
283
+
284
+
285
+ @app.command("create")
286
+ def create_workflow(
287
+ prompt: str = typer.Argument(..., help="Describe the workflow you want to build"),
288
+ name: str = typer.Option("", "--name", "-n", help="Workflow name (optional)"),
289
+ sync: bool = typer.Option(True, "--sync/--async", help="Wait for generation (default: on)"),
290
+ view: bool = typer.Option(False, "--view", help="Open workflow in browser after creation"),
291
+ output_json: bool = typer.Option(False, "--output-json", "-j"),
292
+ ):
293
+ """Generate a new workflow from a text description using the AI architect.
294
+
295
+ Examples:
296
+
297
+ \\b
298
+ muapi workflow create "take a text prompt, generate an image with flux, then upscale it"
299
+ muapi workflow create "text prompt → video with kling → add lipsync audio"
300
+ """
301
+ body = {"prompt": prompt, "sync": sync}
302
+ if name:
303
+ body["name"] = name
304
+
305
+ try:
306
+ data = _post("architect", body)
307
+ except httpx.HTTPStatusError as e:
308
+ error_exit(str(e), exitcodes.ERROR)
309
+
310
+ # If async, returns request_id to poll
311
+ if not sync and "request_id" in data:
312
+ if output_json:
313
+ out.print_json(json.dumps(data))
314
+ else:
315
+ console.print(f"[green]Workflow generation started.[/green] request_id: [bold]{data['request_id']}[/bold]")
316
+ console.print(f"Poll: [bold]muapi workflow poll {data['request_id']}[/bold]")
317
+ return
318
+
319
+ if output_json:
320
+ out.print_json(json.dumps(data))
321
+ return
322
+
323
+ wf = data.get("workflow") or data
324
+ console.print(f"[green]Workflow created:[/green] [bold]{wf.get('id', '')}[/bold] {wf.get('name', '')}")
325
+ if view:
326
+ import webbrowser
327
+ url = _WORKFLOW_BASE.replace("/workflow", "") + f"/workflow/{wf.get('id')}"
328
+ console.print(f"[dim]Opening:[/dim] {url}")
329
+ webbrowser.open(url)
330
+ _visualize(wf)
331
+
332
+
333
+ @app.command("edit")
334
+ def edit_workflow(
335
+ workflow_id: str = typer.Argument(..., help="Workflow ID to edit"),
336
+ prompt: str = typer.Option(..., "--prompt", "-p", help="Describe the change to make"),
337
+ sync: bool = typer.Option(True, "--sync/--async"),
338
+ view: bool = typer.Option(False, "--view", help="Open workflow in browser after edit"),
339
+ output_json: bool = typer.Option(False, "--output-json", "-j"),
340
+ ):
341
+ """Edit an existing workflow using natural language.
342
+
343
+ Examples:
344
+
345
+ \\b
346
+ muapi workflow edit abc123 --prompt "add a face-swap step after the image generation"
347
+ muapi workflow edit abc123 --prompt "change the video model to veo3"
348
+ """
349
+ body = {"prompt": prompt, "workflow_id": workflow_id, "sync": sync}
350
+
351
+ try:
352
+ data = _post("architect", body)
353
+ except httpx.HTTPStatusError as e:
354
+ error_exit(str(e), exitcodes.ERROR)
355
+
356
+ if not sync and "request_id" in data:
357
+ if output_json:
358
+ out.print_json(json.dumps(data))
359
+ else:
360
+ console.print(f"[green]Edit started.[/green] request_id: [bold]{data['request_id']}[/bold]")
361
+ return
362
+
363
+ if output_json:
364
+ out.print_json(json.dumps(data))
365
+ return
366
+
367
+ wf = data.get("workflow") or data
368
+ console.print(f"[green]Workflow updated:[/green] [bold]{wf.get('id', '')}[/bold]")
369
+ if view:
370
+ import webbrowser
371
+ url = _WORKFLOW_BASE.replace("/workflow", "") + f"/workflow/{wf.get('id')}"
372
+ console.print(f"[dim]Opening:[/dim] {url}")
373
+ webbrowser.open(url)
374
+ _visualize(wf)
375
+
376
+
377
+ @app.command("poll")
378
+ def poll_architect(
379
+ request_id: str = typer.Argument(..., help="request_id from async workflow create/edit"),
380
+ output_json: bool = typer.Option(False, "--output-json", "-j"),
381
+ ):
382
+ """Poll an async workflow generation until complete."""
383
+ deadline = time.time() + _MAX_WAIT
384
+ last_status = ""
385
+ while time.time() < deadline:
386
+ try:
387
+ data = _get(f"poll-architect/{request_id}/result")
388
+ except httpx.HTTPStatusError as e:
389
+ error_exit(str(e), exitcodes.ERROR)
390
+
391
+ status = data.get("status", "")
392
+ if status != last_status:
393
+ console.print(f"[dim]status: {status}[/dim]")
394
+ last_status = status
395
+
396
+ if status == "completed":
397
+ if output_json:
398
+ out.print_json(json.dumps(data))
399
+ else:
400
+ wf = data.get("workflow") or data.get("outputs", [{}])[0] if data.get("outputs") else data
401
+ console.print("[green]Workflow ready.[/green]")
402
+ _visualize(wf if isinstance(wf, dict) else data)
403
+ return
404
+ if status == "failed":
405
+ error_exit(f"Workflow generation failed: {data.get('error', 'unknown')}", exitcodes.ERROR)
406
+
407
+ time.sleep(_POLL_INTERVAL)
408
+
409
+ error_exit(f"Timed out waiting for workflow generation.", exitcodes.TIMEOUT)
410
+
411
+
412
+ @app.command("run")
413
+ def run_workflow(
414
+ workflow_id: str = typer.Argument(..., help="Workflow ID to run"),
415
+ webhook: str = typer.Option("", "--webhook", help="Webhook URL for completion notification"),
416
+ wait: bool = typer.Option(True, "--wait/--no-wait", help="Poll until complete (default: on)"),
417
+ output_json: bool = typer.Option(False, "--output-json", "-j"),
418
+ download: str = typer.Option("", "--download", "-d", help="Directory to download output files"),
419
+ ):
420
+ """Start a workflow run (uses last saved inputs)."""
421
+ body = {}
422
+ if webhook:
423
+ body["webhook_url"] = webhook
424
+
425
+ try:
426
+ data = _post(f"{workflow_id}/run", body)
427
+ except httpx.HTTPStatusError as e:
428
+ error_exit(str(e), exitcodes.ERROR)
429
+
430
+ run_id = data.get("run_id") or data.get("id")
431
+ if not run_id:
432
+ if output_json:
433
+ out.print_json(json.dumps(data))
434
+ else:
435
+ console.print(data)
436
+ return
437
+
438
+ console.print(f"[green]Run started.[/green] run_id: [bold]{run_id}[/bold]")
439
+
440
+ if not wait:
441
+ if output_json:
442
+ out.print_json(json.dumps(data))
443
+ return
444
+
445
+ _wait_for_run(run_id, output_json=output_json, download=download)
446
+
447
+
448
+ @app.command("execute")
449
+ def execute_workflow(
450
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
451
+ input: list[str] = typer.Option([], "--input", "-i",
452
+ help="Input as node_id.param=value (repeatable). E.g. --input node1.prompt='a cat'"),
453
+ webhook: str = typer.Option("", "--webhook"),
454
+ wait: bool = typer.Option(True, "--wait/--no-wait"),
455
+ output_json: bool = typer.Option(False, "--output-json", "-j"),
456
+ download: str = typer.Option("", "--download", "-d"),
457
+ ):
458
+ """Execute a workflow with specific inputs.
459
+
460
+ Examples:
461
+
462
+ \\b
463
+ muapi workflow execute abc123 --input "node1.prompt=a glowing crystal"
464
+ muapi workflow execute abc123 --input "text-1.prompt=sunset" --input "img-gen.model=flux-dev"
465
+ """
466
+ # Parse --input node_id.param=value into nested dict
467
+ inputs: dict = {}
468
+ for item in input:
469
+ if "=" not in item or "." not in item.split("=")[0]:
470
+ error_exit(f"Invalid --input format: '{item}'. Use node_id.param=value", exitcodes.VALIDATION)
471
+ key, value = item.split("=", 1)
472
+ node_id, param = key.split(".", 1)
473
+ inputs.setdefault(node_id, {})[param] = value
474
+
475
+ body: dict = {"inputs": inputs}
476
+ if webhook:
477
+ body["webhook_url"] = webhook
478
+
479
+ try:
480
+ data = _post(f"{workflow_id}/api-execute", body)
481
+ except httpx.HTTPStatusError as e:
482
+ error_exit(str(e), exitcodes.ERROR)
483
+
484
+ run_id = data.get("run_id") or data.get("id")
485
+ if not run_id:
486
+ if output_json:
487
+ out.print_json(json.dumps(data))
488
+ else:
489
+ console.print(data)
490
+ return
491
+
492
+ console.print(f"[green]Execution started.[/green] run_id: [bold]{run_id}[/bold]")
493
+
494
+ if not wait:
495
+ if output_json:
496
+ out.print_json(json.dumps(data))
497
+ return
498
+
499
+ _wait_for_run(run_id, output_json=output_json, download=download)
500
+
501
+
502
+ @app.command("run-interactive")
503
+ def interactive_run(
504
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
505
+ webhook: str = typer.Option("", "--webhook"),
506
+ wait: bool = typer.Option(True, "--wait/--no-wait"),
507
+ download: str = typer.Option("", "--download", "-d"),
508
+ ):
509
+ """Run a workflow and interactively prompt for required inputs."""
510
+ try:
511
+ # 1. Fetch Input Schema
512
+ inputs_resp = _get(f"{workflow_id}/api-inputs")
513
+ except httpx.HTTPStatusError as e:
514
+ error_exit(str(e), exitcodes.ERROR)
515
+
516
+ # Note: schema structure from server is nested
517
+ input_props = inputs_resp.get("input_data", {}).get("properties", {})
518
+ if not input_props:
519
+ console.print("[yellow]This workflow has no interactive input nodes.[/yellow]")
520
+ run_workflow(workflow_id, webhook=webhook, wait=wait, download=download)
521
+ return
522
+
523
+ console.print(f"[bold]Interactive Run:[/bold] {workflow_id}")
524
+ console.print("[dim]Please provide values for the following inputs:[/dim]\n")
525
+
526
+ input_pairs = []
527
+ for node_id, meta in input_props.items():
528
+ title = meta.get("title", node_id)
529
+ desc = meta.get("description", "")
530
+ example = meta.get("examples", [""])[0] if meta.get("examples") else ""
531
+ name = meta.get("name", "value")
532
+
533
+ prompt_str = f"[bold cyan]* {title}[/bold cyan] ({node_id})"
534
+ if desc:
535
+ prompt_str += f"\n [dim]{desc}[/dim]"
536
+ if example:
537
+ prompt_str += f"\n [dim]Example: {example}[/dim]"
538
+
539
+ console.print(prompt_str)
540
+ val = typer.prompt(f" Enter {title}")
541
+
542
+ # Format as expected by 'execute' command logic
543
+ input_pairs.append(f"{node_id}.{name}={val}")
544
+
545
+ # 2. Reuse execute_workflow logic
546
+ execute_workflow(
547
+ workflow_id=workflow_id,
548
+ input=input_pairs,
549
+ webhook=webhook,
550
+ wait=wait,
551
+ download=download
552
+ )
553
+
554
+
555
+ @app.command("status")
556
+ def run_status(
557
+ run_id: str = typer.Argument(..., help="Run ID"),
558
+ output_json: bool = typer.Option(False, "--output-json", "-j"),
559
+ ):
560
+ """Get the current node-by-node status of a workflow run."""
561
+ try:
562
+ data = _get(f"run/{run_id}/status")
563
+ except httpx.HTTPStatusError as e:
564
+ error_exit(str(e), exitcodes.ERROR)
565
+
566
+ if output_json:
567
+ out.print_json(json.dumps(data))
568
+ return
569
+
570
+ _print_run_status(data)
571
+
572
+
573
+ @app.command("outputs")
574
+ def run_outputs(
575
+ run_id: str = typer.Argument(..., help="Run ID"),
576
+ output_json: bool = typer.Option(False, "--output-json", "-j"),
577
+ download: str = typer.Option("", "--download", "-d", help="Download output files to directory"),
578
+ ):
579
+ """Get the final outputs of a completed workflow run."""
580
+ try:
581
+ data = _get(f"run/{run_id}/api-outputs")
582
+ except httpx.HTTPStatusError as e:
583
+ error_exit(str(e), exitcodes.ERROR)
584
+
585
+ if output_json:
586
+ out.print_json(json.dumps(data))
587
+ return
588
+
589
+ urls = _extract_output_urls(data)
590
+ if urls:
591
+ console.print("[bold green]Outputs:[/bold green]")
592
+ for url in urls:
593
+ console.print(f" {url}")
594
+ if download:
595
+ _download_urls(urls, download)
596
+ else:
597
+ console.print(data)
598
+
599
+
600
+ @app.command("delete")
601
+ def delete_workflow(
602
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
603
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
604
+ ):
605
+ """Delete a workflow definition."""
606
+ if not yes:
607
+ typer.confirm(f"Delete workflow {workflow_id}?", abort=True)
608
+ try:
609
+ data = _get(f"delete-workflow-def/{workflow_id}") # DELETE verb needed
610
+ except Exception:
611
+ # Try DELETE method
612
+ resp = httpx.delete(
613
+ f"{_WORKFLOW_BASE}/delete-workflow-def/{workflow_id}",
614
+ headers=_headers(), timeout=30.0
615
+ )
616
+ if resp.status_code >= 400:
617
+ error_exit(resp.text, exitcodes.ERROR)
618
+ data = resp.json()
619
+ console.print(f"[green]Workflow {workflow_id} deleted.[/green]")
620
+
621
+
622
+ @app.command("rename")
623
+ def rename_workflow(
624
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
625
+ name: str = typer.Option(..., "--name", "-n", help="New name"),
626
+ ):
627
+ """Rename a workflow."""
628
+ try:
629
+ data = _post(f"update-name/{workflow_id}", {"name": name})
630
+ except httpx.HTTPStatusError as e:
631
+ error_exit(str(e), exitcodes.ERROR)
632
+ console.print(f"[green]Renamed to:[/green] {name}")
633
+
634
+
635
+ # ── Helpers ───────────────────────────────────────────────────────────────────
636
+
637
+ def _print_run_status(data: dict) -> None:
638
+ """Pretty-print node-by-node run status."""
639
+ from rich.table import Table
640
+ nodes = data.get("nodes") or data.get("node_statuses") or []
641
+ if isinstance(nodes, dict):
642
+ flat_nodes = []
643
+ for v in nodes.values():
644
+ if isinstance(v, list):
645
+ flat_nodes.extend(v)
646
+ else:
647
+ flat_nodes.append(v)
648
+ nodes = flat_nodes
649
+
650
+ overall = data.get("status", "")
651
+
652
+ console.print(f"[bold]Run status:[/bold] {overall}")
653
+ if not nodes:
654
+ console.print(data)
655
+ return
656
+
657
+ table = Table(show_header=True, header_style="bold")
658
+ table.add_column("Node ID")
659
+ table.add_column("Type")
660
+ table.add_column("Status")
661
+ table.add_column("Output")
662
+ for n in nodes:
663
+ status = n.get("status", "")
664
+ color = "green" if status == "completed" else "yellow" if status == "processing" else "red" if status == "failed" else "dim"
665
+ outputs = n.get("outputs") or []
666
+ out_str = outputs[0][:60] + "…" if outputs else ""
667
+ table.add_row(
668
+ n.get("id", ""),
669
+ n.get("type", ""),
670
+ f"[{color}]{status}[/{color}]",
671
+ out_str,
672
+ )
673
+ console.print(table)
674
+
675
+
676
+ def _extract_output_urls(data: dict) -> list[str]:
677
+ urls = []
678
+ outputs = data.get("outputs") or []
679
+ for item in outputs:
680
+ if isinstance(item, str):
681
+ urls.append(item)
682
+ elif isinstance(item, dict):
683
+ # Handle both formats: {"outputs": [...]} and {"value": "url"}
684
+ if item.get("outputs"):
685
+ urls.extend(item["outputs"])
686
+ elif item.get("value"):
687
+ urls.append(item["value"])
688
+ return urls
689
+
690
+
691
+ def _download_urls(urls: list[str], dest: str) -> None:
692
+ import pathlib
693
+ pathlib.Path(dest).mkdir(parents=True, exist_ok=True)
694
+ for url in urls:
695
+ fname = url.split("?")[0].split("/")[-1] or "output"
696
+ path = pathlib.Path(dest) / fname
697
+ console.print(f" Downloading → {path}")
698
+ with httpx.Client() as c:
699
+ r = c.get(url, follow_redirects=True, timeout=120.0)
700
+ path.write_bytes(r.content)
701
+
702
+
703
+ def _wait_for_run(run_id: str, output_json: bool = False, download: str = "") -> None:
704
+ """Poll run/{run_id}/status until done, then fetch outputs."""
705
+ deadline = time.time() + _MAX_WAIT
706
+ last_status = ""
707
+
708
+ with console.status("[dim]Waiting for workflow run…[/dim]") as spinner:
709
+ while time.time() < deadline:
710
+ try:
711
+ data = _get(f"run/{run_id}/status")
712
+ except httpx.HTTPStatusError as e:
713
+ error_exit(str(e), exitcodes.ERROR)
714
+
715
+ status = data.get("status", "")
716
+ if status != last_status:
717
+ spinner.update(f"[dim]{status}[/dim]")
718
+ last_status = status
719
+
720
+ if status == "completed":
721
+ spinner.stop()
722
+ _print_run_status(data)
723
+ # Fetch API outputs
724
+ try:
725
+ out_data = _get(f"run/{run_id}/api-outputs")
726
+ urls = _extract_output_urls(out_data)
727
+ if urls:
728
+ console.print("\n[bold green]Outputs:[/bold green]")
729
+ for url in urls:
730
+ console.print(f" {url}")
731
+ if download:
732
+ _download_urls(urls, download)
733
+ elif output_json:
734
+ out.print_json(json.dumps(out_data))
735
+ except Exception:
736
+ pass
737
+ return
738
+
739
+ if status == "failed":
740
+ spinner.stop()
741
+ _print_run_status(data)
742
+ error_exit("Workflow run failed.", exitcodes.ERROR)
743
+
744
+ time.sleep(_POLL_INTERVAL)
745
+
746
+ error_exit(f"Timed out after {_MAX_WAIT}s. Run ID: {run_id}", exitcodes.TIMEOUT)