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.
- muapi/__init__.py +1 -0
- muapi/client.py +121 -0
- muapi/commands/__init__.py +0 -0
- muapi/commands/account.py +89 -0
- muapi/commands/audio.py +139 -0
- muapi/commands/auth.py +193 -0
- muapi/commands/config_cmd.py +80 -0
- muapi/commands/docs.py +81 -0
- muapi/commands/edit.py +134 -0
- muapi/commands/enhance.py +157 -0
- muapi/commands/image.py +297 -0
- muapi/commands/keys.py +115 -0
- muapi/commands/mcp_server.py +905 -0
- muapi/commands/models.py +79 -0
- muapi/commands/predict.py +43 -0
- muapi/commands/run.py +173 -0
- muapi/commands/upload.py +31 -0
- muapi/commands/video.py +318 -0
- muapi/commands/workflow.py +746 -0
- muapi/config.py +110 -0
- muapi/dynamic_help.py +144 -0
- muapi/exitcodes.py +14 -0
- muapi/main.py +98 -0
- muapi/schema_introspect.py +175 -0
- muapi/utils.py +202 -0
- muapi_cli-0.2.5.dist-info/METADATA +337 -0
- muapi_cli-0.2.5.dist-info/RECORD +29 -0
- muapi_cli-0.2.5.dist-info/WHEEL +4 -0
- muapi_cli-0.2.5.dist-info/entry_points.txt +2 -0
|
@@ -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)
|