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/config.py ADDED
@@ -0,0 +1,110 @@
1
+ """Config management — stores API key in OS keychain, with ~/.muapi/config.json fallback."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ _CONFIG_DIR = Path.home() / ".muapi"
8
+ _CONFIG_FILE = _CONFIG_DIR / "config.json"
9
+ _KEYRING_SERVICE = "muapi-cli"
10
+ _KEYRING_USER = "api-key"
11
+
12
+ BASE_URL = os.environ.get("MUAPI_BASE_URL", "https://api.muapi.ai/api/v1")
13
+
14
+
15
+ def _try_keyring() -> tuple[bool, Optional[str]]:
16
+ try:
17
+ import keyring
18
+ val = keyring.get_password(_KEYRING_SERVICE, _KEYRING_USER)
19
+ return True, val
20
+ except Exception:
21
+ return False, None
22
+
23
+
24
+ def get_api_key() -> Optional[str]:
25
+ # 1. Environment variable always wins
26
+ if key := os.environ.get("MUAPI_API_KEY"):
27
+ return key
28
+ # 2. OS keychain
29
+ ok, val = _try_keyring()
30
+ if ok and val:
31
+ return val
32
+ # 3. Config file fallback
33
+ if _CONFIG_FILE.exists():
34
+ try:
35
+ data = json.loads(_CONFIG_FILE.read_text())
36
+ return data.get("api_key")
37
+ except Exception:
38
+ pass
39
+ return None
40
+
41
+
42
+ def save_api_key(api_key: str) -> str:
43
+ """Save API key; returns where it was saved ('keychain' or 'file')."""
44
+ ok, _ = _try_keyring()
45
+ if ok:
46
+ import keyring
47
+ keyring.set_password(_KEYRING_SERVICE, _KEYRING_USER, api_key)
48
+ return "keychain"
49
+ # Fallback: write to file
50
+ _CONFIG_DIR.mkdir(parents=True, exist_ok=True)
51
+ existing: dict = {}
52
+ if _CONFIG_FILE.exists():
53
+ try:
54
+ existing = json.loads(_CONFIG_FILE.read_text())
55
+ except Exception:
56
+ pass
57
+ existing["api_key"] = api_key
58
+ _CONFIG_FILE.write_text(json.dumps(existing, indent=2))
59
+ _CONFIG_FILE.chmod(0o600)
60
+ return "file"
61
+
62
+
63
+ def get_setting(key: str) -> Optional[str]:
64
+ """Read a value from the settings section of the config file."""
65
+ if _CONFIG_FILE.exists():
66
+ try:
67
+ data = json.loads(_CONFIG_FILE.read_text())
68
+ return data.get("settings", {}).get(key)
69
+ except Exception:
70
+ pass
71
+ return None
72
+
73
+
74
+ def set_setting(key: str, value: str) -> None:
75
+ """Write a value to the settings section of the config file."""
76
+ _CONFIG_DIR.mkdir(parents=True, exist_ok=True)
77
+ existing: dict = {}
78
+ if _CONFIG_FILE.exists():
79
+ try:
80
+ existing = json.loads(_CONFIG_FILE.read_text())
81
+ except Exception:
82
+ pass
83
+ existing.setdefault("settings", {})[key] = value
84
+ _CONFIG_FILE.write_text(json.dumps(existing, indent=2))
85
+ _CONFIG_FILE.chmod(0o600)
86
+
87
+
88
+ def get_all_settings() -> dict:
89
+ """Return all settings as a dict."""
90
+ if _CONFIG_FILE.exists():
91
+ try:
92
+ data = json.loads(_CONFIG_FILE.read_text())
93
+ return data.get("settings", {})
94
+ except Exception:
95
+ pass
96
+ return {}
97
+
98
+
99
+ def delete_api_key() -> None:
100
+ ok, _ = _try_keyring()
101
+ if ok:
102
+ try:
103
+ import keyring
104
+ keyring.delete_password(_KEYRING_SERVICE, _KEYRING_USER)
105
+ except Exception:
106
+ pass
107
+ if _CONFIG_FILE.exists():
108
+ data = json.loads(_CONFIG_FILE.read_text())
109
+ data.pop("api_key", None)
110
+ _CONFIG_FILE.write_text(json.dumps(data, indent=2))
muapi/dynamic_help.py ADDED
@@ -0,0 +1,144 @@
1
+ """Dynamic per-model `--help` for `muapi run <model>`.
2
+
3
+ Typer's static help only knows about flags that are *declared* on a command,
4
+ so it can't show per-model inputs. We sniff argv before Typer runs, and if
5
+ the user is asking for help on `muapi run <model>`, we fetch the model's
6
+ OpenAPI schema and print its real properties.
7
+
8
+ If anything goes wrong (network, unknown model, malformed spec), we return
9
+ False so the caller falls through to Typer's normal static help.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from typing import Optional
15
+
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from . import schema_introspect
20
+
21
+
22
+ _HELP_FLAGS = {"-h", "--help"}
23
+
24
+
25
+ def detect_run_help(argv: list[str]) -> Optional[str]:
26
+ """Return the model token if argv looks like `muapi run <model> -h`.
27
+
28
+ The check is intentionally permissive — extra flags between `run`
29
+ and `-h` don't disqualify (e.g. `run flux-dev -p foo -h`).
30
+ """
31
+ try:
32
+ run_idx = argv.index("run")
33
+ except ValueError:
34
+ return None
35
+ rest = argv[run_idx + 1:]
36
+ if not rest:
37
+ return None
38
+ if not any(flag in rest for flag in _HELP_FLAGS):
39
+ return None
40
+ # First non-flag token after `run` is the model.
41
+ for token in rest:
42
+ if token in _HELP_FLAGS:
43
+ return None # asked for help with no model
44
+ if not token.startswith("-"):
45
+ return token
46
+ return None
47
+
48
+
49
+ def print_dynamic_help(model: str) -> bool:
50
+ """Print schema-driven help for `model`. Returns True on success."""
51
+ # Resolve aliases the same way the run command does.
52
+ try:
53
+ from .commands.run import resolve_model
54
+ endpoint = resolve_model(model)
55
+ except Exception:
56
+ endpoint = model
57
+
58
+ try:
59
+ described = schema_introspect.lookup(endpoint)
60
+ except Exception:
61
+ return False
62
+ if not described:
63
+ return False
64
+
65
+ console = Console(stderr=False)
66
+ console.print()
67
+ console.print(f"[bold magenta]muapi run {model}[/bold magenta]")
68
+ if endpoint != model:
69
+ console.print(f" [dim]endpoint:[/dim] {endpoint}")
70
+ if described["title"] and described["title"] != endpoint:
71
+ console.print(f" [dim]schema: [/dim] {described['title']}")
72
+ console.print()
73
+ console.print("[bold]Usage:[/bold] muapi run "
74
+ f"{model} [-p PROMPT] [-i KEY=VALUE ...] [--input-file FILE] [global opts]")
75
+ console.print()
76
+
77
+ props = described["properties"]
78
+ if not props:
79
+ console.print("[dim]No input properties documented for this endpoint.[/dim]")
80
+ console.print()
81
+ else:
82
+ table = Table(show_header=True, header_style="bold cyan", title="Inputs (from live OpenAPI schema)")
83
+ table.add_column("name")
84
+ table.add_column("type")
85
+ table.add_column("required")
86
+ table.add_column("default")
87
+ table.add_column("description / enum")
88
+ for p in props:
89
+ default = "" if p["default"] is None else _short(p["default"])
90
+ desc = p["description"]
91
+ if p["enum"]:
92
+ desc = (desc + " " if desc else "") + f"[dim]enum:[/dim] {', '.join(map(str, p['enum']))}"
93
+ table.add_row(
94
+ p["name"],
95
+ p["type"],
96
+ "[red]✓[/red]" if p["required"] else "",
97
+ default,
98
+ desc,
99
+ )
100
+ console.print(table)
101
+ console.print()
102
+
103
+ console.print("[bold]Global options:[/bold]")
104
+ console.print(" -p, --prompt TEXT Prompt (also pass via -i prompt=...). Use '-' for stdin.")
105
+ console.print(" -i, --input KEY=VALUE Repeatable. Values are parsed as JSON when valid.")
106
+ console.print(" --input-file FILE JSON file of inputs (merged before -i flags).")
107
+ console.print(" --wait / --no-wait Poll until done (default: --wait).")
108
+ console.print(" --dry-run Print the request that would be sent and exit.")
109
+ console.print(" --download DIR, -d Save outputs to DIR.")
110
+ console.print(" --output-json, -j Print raw JSON to stdout.")
111
+ console.print(" --jq EXPR jq-style filter on JSON output.")
112
+ console.print()
113
+ console.print("[dim]Example:[/dim] muapi run "
114
+ f"{model} -p \"...\" {_example_inputs(props)}--output-json")
115
+ console.print()
116
+ return True
117
+
118
+
119
+ def _short(val: object) -> str:
120
+ s = repr(val) if isinstance(val, str) else str(val)
121
+ return s if len(s) <= 32 else s[:29] + "..."
122
+
123
+
124
+ def _example_inputs(props: list[dict]) -> str:
125
+ """Suggest a couple of `-i` flags from the schema as an example."""
126
+ bits = []
127
+ for p in props:
128
+ if p["name"] == "prompt":
129
+ continue
130
+ if p["default"] is not None and not isinstance(p["default"], (list, dict)):
131
+ bits.append(f"-i {p['name']}={p['default']}")
132
+ elif p["enum"]:
133
+ bits.append(f"-i {p['name']}={p['enum'][0]}")
134
+ if len(bits) >= 2:
135
+ break
136
+ return (" ".join(bits) + " ") if bits else ""
137
+
138
+
139
+ def maybe_handle_run_help(argv: Optional[list[str]] = None) -> bool:
140
+ """Top-level entry: if argv asks for `run <model> -h`, print + return True."""
141
+ model = detect_run_help(list(argv) if argv is not None else sys.argv)
142
+ if not model:
143
+ return False
144
+ return print_dynamic_help(model)
muapi/exitcodes.py ADDED
@@ -0,0 +1,14 @@
1
+ """Semantic exit codes for agent-friendly scripting.
2
+
3
+ Exit codes match the convention established by modelslab-cli and common
4
+ Unix tool practices so agents can branch on specific failure types.
5
+ """
6
+
7
+ OK = 0 # Success
8
+ ERROR = 1 # Generic / unclassified error
9
+ AUTH_ERROR = 3 # Missing or invalid API key
10
+ RATE_LIMITED = 4 # 429 Too Many Requests
11
+ NOT_FOUND = 5 # 404 — resource or model not found
12
+ BILLING_ERROR = 6 # Insufficient credits / payment required
13
+ TIMEOUT = 7 # Generation timed out
14
+ VALIDATION = 8 # Bad input / schema validation failure
muapi/main.py ADDED
@@ -0,0 +1,98 @@
1
+ """muapi CLI — official command-line interface for muapi.ai"""
2
+ from typing import Optional
3
+
4
+ import typer
5
+ from rich import print as rprint
6
+
7
+ from . import __version__
8
+ from .commands import auth, account, audio, config_cmd, docs, edit, enhance, image, keys, models, predict, run, upload, video, workflow
9
+ from .commands import mcp_server
10
+ from .dynamic_help import maybe_handle_run_help
11
+
12
+ app = typer.Typer(
13
+ name="muapi",
14
+ help="muapi.ai CLI — generate images, videos, and audio from the terminal.",
15
+ add_completion=True,
16
+ rich_markup_mode="rich",
17
+ no_args_is_help=True,
18
+ context_settings={"help_option_names": ["-h", "--help"]},
19
+ )
20
+
21
+ # ── Subcommand groups ──────────────────────────────────────────────────────────
22
+
23
+ app.add_typer(auth.app, name="auth", help="Log in, register, or configure API key.")
24
+ app.add_typer(account.app, name="account", help="Check balance and top up credits.")
25
+ app.add_typer(keys.app, name="keys", help="List, create, and delete API keys.")
26
+ app.add_typer(image.app, name="image", help="Generate or edit images.")
27
+ app.add_typer(video.app, name="video", help="Generate videos from text or images.")
28
+ app.add_typer(audio.app, name="audio", help="Create or remix music and audio.")
29
+ app.add_typer(enhance.app, name="enhance", help="Enhance images (upscale, bg-remove, face-swap…).")
30
+ app.add_typer(edit.app, name="edit", help="Edit videos (effects, lipsync, dance, dress…).")
31
+ app.add_typer(predict.app, name="predict", help="Check or wait for async prediction results.")
32
+
33
+ # `run` is a single top-level command, not a group, so its positional MODEL
34
+ # argument doesn't collide with subcommand routing.
35
+ app.command(
36
+ "run",
37
+ help="Run any model by endpoint name (schema-driven; try `muapi run <model> -h`).",
38
+ context_settings={"help_option_names": ["-h", "--help"]},
39
+ )(run.run)
40
+ app.add_typer(upload.app, name="upload", help="Upload local files to get a hosted URL.")
41
+ app.add_typer(models.app, name="models", help="Discover all available models.")
42
+ app.add_typer(workflow.app, name="workflow", help="Build, run, and visualize multi-step AI workflows.")
43
+ app.add_typer(config_cmd.app, name="config", help="Get and set persistent CLI configuration.")
44
+ app.add_typer(docs.app, name="docs", help="Access the muapi.ai API documentation.")
45
+ app.add_typer(mcp_server.app, name="mcp", help="Run as an MCP server for AI agent integration.")
46
+
47
+
48
+ @app.command("version")
49
+ def version(
50
+ output_json: bool = typer.Option(False, "--output-json", "-j"),
51
+ ):
52
+ """Show the muapi CLI version."""
53
+ if output_json:
54
+ import json
55
+ from .utils import out
56
+ out.print_json(json.dumps({"version": __version__, "name": "muapi-cli"}))
57
+ else:
58
+ rprint(f"muapi CLI [bold]{__version__}[/bold]")
59
+
60
+
61
+ @app.callback(invoke_without_command=True)
62
+ def main(
63
+ ctx: typer.Context,
64
+ no_color: bool = typer.Option(
65
+ False, "--no-color",
66
+ help="Disable colored output (also respects NO_COLOR env var)",
67
+ is_eager=True,
68
+ ),
69
+ version_flag: bool = typer.Option(
70
+ False, "--version", "-V",
71
+ help="Show version and exit",
72
+ is_eager=True,
73
+ ),
74
+ ):
75
+ if no_color:
76
+ from .utils import disable_color
77
+ disable_color()
78
+
79
+ if version_flag:
80
+ rprint(f"muapi CLI [bold]{__version__}[/bold]")
81
+ raise typer.Exit()
82
+
83
+ if ctx.invoked_subcommand is None:
84
+ rprint(ctx.get_help())
85
+
86
+
87
+ def _entrypoint() -> None:
88
+ # Intercept `muapi run <model> -h` so we can print model-specific
89
+ # input help from the live OpenAPI schema. Falls through to Typer
90
+ # on any failure (network down, unknown model, missing schema).
91
+ import sys
92
+ if maybe_handle_run_help(sys.argv[1:]):
93
+ return
94
+ app()
95
+
96
+
97
+ if __name__ == "__main__":
98
+ _entrypoint()
@@ -0,0 +1,175 @@
1
+ """Fetch the muapi OpenAPI spec and resolve per-endpoint request schemas.
2
+
3
+ The CLI's static verb commands wrap a curated subset of models. `muapi run`
4
+ needs to reach *any* endpoint exposed by the API and discover its input
5
+ schema at call time — so we read the live OpenAPI spec and look up the
6
+ request body for the endpoint the user named.
7
+
8
+ The spec is cached on disk (~/.muapi/openapi-cache.json, 1h TTL) so repeated
9
+ `muapi run ... -h` calls don't hit the network.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any, Optional
17
+
18
+ import httpx
19
+
20
+ from .config import BASE_URL
21
+
22
+ _HOST = BASE_URL.replace("/api/v1", "")
23
+ _OPENAPI_URL = f"{_HOST}/openapi.json"
24
+
25
+ _CACHE_DIR = Path.home() / ".muapi"
26
+ _CACHE_FILE = _CACHE_DIR / "openapi-cache.json"
27
+ _CACHE_TTL = 3600 # 1 hour, matches WaveSpeed's `models.json` cache
28
+
29
+ _API_PREFIX = "/api/v1/"
30
+
31
+
32
+ def _load_cache() -> Optional[dict]:
33
+ if not _CACHE_FILE.exists():
34
+ return None
35
+ try:
36
+ wrapper = json.loads(_CACHE_FILE.read_text())
37
+ if time.time() - wrapper.get("fetched_at", 0) > _CACHE_TTL:
38
+ return None
39
+ if wrapper.get("base_url") != BASE_URL:
40
+ return None
41
+ return wrapper.get("spec")
42
+ except Exception:
43
+ return None
44
+
45
+
46
+ def _save_cache(spec: dict) -> None:
47
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
48
+ payload = {"base_url": BASE_URL, "fetched_at": time.time(), "spec": spec}
49
+ try:
50
+ _CACHE_FILE.write_text(json.dumps(payload))
51
+ except Exception:
52
+ pass # cache failure shouldn't break the call
53
+
54
+
55
+ def fetch_spec(force_refresh: bool = False, timeout: float = 15.0) -> dict:
56
+ """Return the OpenAPI spec, served from cache when fresh."""
57
+ if not force_refresh:
58
+ cached = _load_cache()
59
+ if cached:
60
+ return cached
61
+ resp = httpx.get(_OPENAPI_URL, timeout=timeout)
62
+ resp.raise_for_status()
63
+ spec = resp.json()
64
+ _save_cache(spec)
65
+ return spec
66
+
67
+
68
+ # ── Lookup ────────────────────────────────────────────────────────────────────
69
+
70
+ def _resolve_ref(spec: dict, ref: str) -> dict:
71
+ # "#/components/schemas/ImageRequest" → dict
72
+ if not ref.startswith("#/"):
73
+ return {}
74
+ node: Any = spec
75
+ for part in ref[2:].split("/"):
76
+ if not isinstance(node, dict) or part not in node:
77
+ return {}
78
+ node = node[part]
79
+ return node if isinstance(node, dict) else {}
80
+
81
+
82
+ def find_endpoint(spec: dict, endpoint: str) -> Optional[dict]:
83
+ """Locate the POST operation for an endpoint name (with or without /api/v1/ prefix)."""
84
+ paths = spec.get("paths", {})
85
+ # Try a few common forms — users pass the endpoint slug, not the full path.
86
+ candidates = [endpoint]
87
+ if not endpoint.startswith("/"):
88
+ candidates.append(f"{_API_PREFIX}{endpoint}")
89
+ candidates.append(f"/{endpoint}")
90
+ for candidate in candidates:
91
+ node = paths.get(candidate)
92
+ if node and "post" in node:
93
+ return node["post"]
94
+ return None
95
+
96
+
97
+ def get_request_schema(spec: dict, endpoint: str) -> Optional[dict]:
98
+ """Return the resolved JSON schema for the endpoint's request body, or None."""
99
+ op = find_endpoint(spec, endpoint)
100
+ if not op:
101
+ return None
102
+ body = op.get("requestBody", {})
103
+ content = body.get("content", {}).get("application/json", {})
104
+ schema = content.get("schema", {})
105
+ # Follow a single $ref hop — that's all muapi's spec uses today.
106
+ if "$ref" in schema:
107
+ return _resolve_ref(spec, schema["$ref"])
108
+ return schema or None
109
+
110
+
111
+ def _format_type(prop: dict) -> str:
112
+ """Best-effort one-line type label for a JSON schema property."""
113
+ if "enum" in prop:
114
+ return "enum"
115
+ if "anyOf" in prop:
116
+ types = []
117
+ for sub in prop["anyOf"]:
118
+ t = sub.get("type")
119
+ if t and t != "null":
120
+ types.append(t)
121
+ return " | ".join(types) if types else "any"
122
+ if "type" in prop:
123
+ t = prop["type"]
124
+ if t == "array":
125
+ item_type = prop.get("items", {}).get("type", "any")
126
+ return f"array<{item_type}>"
127
+ return t
128
+ if "$ref" in prop:
129
+ ref = prop["$ref"].rsplit("/", 1)[-1]
130
+ return f"object<{ref}>"
131
+ return "any"
132
+
133
+
134
+ def describe_schema(schema: dict) -> dict:
135
+ """Normalize a JSON schema for display.
136
+
137
+ Returns: {title, properties: [(name, type, required, default, enum, description)]}
138
+ """
139
+ title = schema.get("title", "")
140
+ required = set(schema.get("required", []))
141
+ rows = []
142
+ for name, prop in schema.get("properties", {}).items():
143
+ if not isinstance(prop, dict):
144
+ continue
145
+ rows.append({
146
+ "name": name,
147
+ "type": _format_type(prop),
148
+ "required": name in required,
149
+ "default": prop.get("default", None),
150
+ "enum": prop.get("enum"),
151
+ "description": prop.get("description") or prop.get("title") or "",
152
+ })
153
+ # Sort: required first, then alphabetical.
154
+ rows.sort(key=lambda r: (not r["required"], r["name"]))
155
+ return {"title": title, "properties": rows}
156
+
157
+
158
+ # ── Public convenience ───────────────────────────────────────────────────────
159
+
160
+ def lookup(endpoint: str, *, force_refresh: bool = False) -> Optional[dict]:
161
+ """Fetch + extract + describe in one call. Returns None if not found."""
162
+ spec = fetch_spec(force_refresh=force_refresh)
163
+ schema = get_request_schema(spec, endpoint)
164
+ if not schema:
165
+ return None
166
+ return describe_schema(schema)
167
+
168
+
169
+ def list_endpoint_slugs(spec: dict) -> list[str]:
170
+ """Return every POST endpoint slug under /api/v1/."""
171
+ slugs = []
172
+ for path in spec.get("paths", {}):
173
+ if path.startswith(_API_PREFIX) and "post" in spec["paths"][path]:
174
+ slugs.append(path[len(_API_PREFIX):])
175
+ return slugs