Skip to content

Commit ff738c7

Browse files
Anil-matchaclaude
andcommitted
feat: improved login UX — configure, status, init, open
muapi auth configure: - opens https://muapi.ai/access-keys in browser automatically - detects clipboard for a key-shaped string, offers to use it - validates key against live API before saving (fails loudly on 401/403) - shows masked key + storage location + "Try it" examples on success - --no-browser flag to skip browser open; --api-key to skip all prompts muapi auth status (new): - shows key (masked), source (env/keychain/file), base URL, config path - detects muapi.json project config in directory tree - prints useful links table + "muapi open <target>" hint - whoami is now an alias for status muapi init (new top-level command): - creates muapi.json with defaultModel, outputDir, aliases in CWD - -y/--yes for silent defaults, -f/--force to overwrite muapi open (new top-level command): - opens dashboard, access-keys, models, docs, pricing, api, discord in browser - defaults to dashboard when called with no argument Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 96bcd39 commit ff738c7

5 files changed

Lines changed: 320 additions & 17 deletions

File tree

muapi/commands/auth.py

Lines changed: 181 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,121 @@
11
"""muapi auth — configure API key and inspect identity."""
2-
import typer
2+
import os
3+
import re
4+
import subprocess
5+
import sys
6+
import webbrowser
7+
38
import httpx
4-
from rich.prompt import Prompt
9+
import typer
10+
from rich.prompt import Confirm, Prompt
511

6-
from ..config import delete_api_key, get_api_key, save_api_key, BASE_URL
12+
from ..config import BASE_URL, _CONFIG_FILE, delete_api_key, get_api_key, get_key_info, save_api_key
713
from .. import exitcodes
814
from ..utils import console, error_exit, out
915

1016
app = typer.Typer(help="Manage authentication and API key.")
1117

12-
# Auth endpoints live at the root host, not under /api/v1
1318
_AUTH_BASE = BASE_URL.replace("/api/v1", "")
19+
_ACCESS_KEYS_URL = "https://muapi.ai/access-keys"
20+
21+
LINKS = {
22+
"dashboard": "https://muapi.ai/dashboard",
23+
"access-keys": _ACCESS_KEYS_URL,
24+
"models": "https://muapi.ai/models",
25+
"docs": "https://muapi.ai/docs",
26+
"pricing": "https://muapi.ai/pricing",
27+
}
28+
29+
30+
def _mask(key: str) -> str:
31+
if len(key) < 12:
32+
return "••••"
33+
return key[:8] + "…" + key[-4:]
34+
35+
36+
def _looks_like_key(s: str) -> bool:
37+
s = s.strip()
38+
return bool(re.match(r'^[A-Za-z0-9_\-]{20,}$', s) and '\n' not in s)
39+
40+
41+
def _read_clipboard() -> str | None:
42+
try:
43+
if sys.platform == "darwin":
44+
r = subprocess.run(["pbpaste"], capture_output=True, text=True, timeout=2)
45+
return r.stdout.strip() or None
46+
if sys.platform.startswith("linux"):
47+
for cmd in (["xclip", "-o"], ["xsel", "--clipboard", "--output"]):
48+
try:
49+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=2)
50+
if r.returncode == 0:
51+
return r.stdout.strip() or None
52+
except FileNotFoundError:
53+
continue
54+
if sys.platform == "win32":
55+
r = subprocess.run(
56+
["powershell", "-command", "Get-Clipboard"],
57+
capture_output=True, text=True, timeout=2,
58+
)
59+
return r.stdout.strip() or None
60+
except Exception:
61+
pass
62+
return None
63+
64+
65+
def _validate_key(api_key: str) -> tuple[bool, str]:
66+
"""Validate key against the live API. Returns (ok, error_msg)."""
67+
try:
68+
resp = httpx.get(
69+
f"{BASE_URL}/account/balance",
70+
headers={"x-api-key": api_key},
71+
timeout=15.0,
72+
)
73+
if resp.status_code in (401, 403):
74+
return False, "API rejected the key (401/403)."
75+
if resp.status_code >= 400:
76+
return False, f"API returned {resp.status_code}."
77+
return True, ""
78+
except httpx.RequestError as e:
79+
return False, f"Could not reach {BASE_URL}: {e}"
80+
81+
82+
def _find_project_config() -> str | None:
83+
"""Walk up from CWD looking for muapi.json."""
84+
from pathlib import Path
85+
d = Path.cwd()
86+
while True:
87+
candidate = d / "muapi.json"
88+
if candidate.exists():
89+
return str(candidate)
90+
parent = d.parent
91+
if parent == d:
92+
return None
93+
d = parent
94+
95+
96+
def _do_save(api_key: str) -> None:
97+
with console.status("[dim]Validating with api.muapi.ai…[/dim]"):
98+
ok, reason = _validate_key(api_key)
99+
100+
if not ok:
101+
console.print(f"[bold red]✖[/bold red] {reason}")
102+
console.print()
103+
console.print(f"[dim] Double-check at [/dim][cyan]{_ACCESS_KEYS_URL}[/cyan]")
104+
console.print("[dim] Or set [/dim][cyan]MUAPI_API_KEY[/cyan][dim] in your shell and skip this step.[/dim]\n")
105+
raise typer.Exit(exitcodes.AUTH_ERROR)
106+
107+
location = save_api_key(api_key)
108+
location_display = "OS keychain" if location == "keychain" else str(_CONFIG_FILE)
109+
console.print("[bold green]✔[/bold green] Signed in.")
110+
console.print()
111+
console.print(f" [dim]Key: [/dim][green]{_mask(api_key)}[/green]")
112+
console.print(f" [dim]Stored: [/dim][cyan]{location_display}[/cyan]")
113+
console.print()
114+
console.print("[bold]Try it:[/bold]")
115+
console.print(" [cyan]muapi account balance[/cyan]")
116+
console.print(" [cyan]muapi image generate -p \"a cyberpunk skyline at golden hour\"[/cyan]")
117+
console.print(" [cyan]muapi video generate -p \"drone shot over snowy peaks\" --model kling-master[/cyan]")
118+
console.print()
14119

15120

16121
@app.command("login")
@@ -165,25 +270,85 @@ def reset_password(
165270

166271
@app.command("configure")
167272
def configure(
168-
api_key: str = typer.Option(None, "--api-key", "-k", help="API key (will prompt if omitted)"),
273+
api_key: str = typer.Option(None, "--api-key", "-k", help="API key (skips all prompts)"),
274+
no_browser: bool = typer.Option(False, "--no-browser", help="Skip opening the access-keys page"),
169275
):
170-
"""Save your muapi API key to the OS keychain (or config file)."""
276+
"""Save your muapi API key — opens browser, detects clipboard, validates before saving."""
277+
if api_key:
278+
_do_save(api_key.strip())
279+
return
280+
281+
console.print()
282+
console.print("[bold magenta] Welcome to muapi.[/bold magenta]")
283+
console.print("[dim] Sign in once and you're set on this machine.[/dim]\n")
284+
285+
if not no_browser:
286+
console.print(f"[bold] 1.[/bold] Opening [cyan]{_ACCESS_KEYS_URL}[/cyan]")
287+
try:
288+
webbrowser.open(_ACCESS_KEYS_URL)
289+
except Exception:
290+
console.print("[dim] (browser launch failed — open the link manually)[/dim]")
291+
console.print("[bold] 2.[/bold] Copy your API key")
292+
console.print("[bold] 3.[/bold] Paste below — we'll validate it automatically\n")
293+
294+
# Clipboard detection
295+
detected: str | None = None
296+
clip = _read_clipboard()
297+
if clip and _looks_like_key(clip):
298+
detected = clip
299+
300+
if detected:
301+
use_it = Confirm.ask(
302+
f" Detected a key on your clipboard ({_mask(detected)}). Use it?",
303+
default=True,
304+
console=console,
305+
)
306+
if use_it:
307+
api_key = detected
308+
171309
if not api_key:
172-
api_key = Prompt.ask("[bold]Enter your muapi API key[/bold]", password=True, console=console)
310+
api_key = Prompt.ask("[bold] Paste your API key[/bold]", password=True, console=console)
311+
api_key = api_key.strip()
312+
173313
if not api_key:
174-
error_exit("No API key provided.")
175-
location = save_api_key(api_key.strip())
176-
console.print(f"[green]API key saved to {location}.[/green]")
314+
error_exit("No API key provided.", exitcodes.AUTH_ERROR)
315+
316+
_do_save(api_key)
317+
318+
319+
@app.command("status")
320+
def status():
321+
"""Show the active API key, config location, base URL, and quick links."""
322+
key, source = get_key_info()
323+
324+
console.print()
325+
console.print("[bold]muapi CLI status[/bold]")
326+
if key:
327+
console.print(f" [dim]API key: [/dim][green]{_mask(key)}[/green]")
328+
else:
329+
console.print(f" [dim]API key: [/dim][red]not set — run [bold]muapi auth configure[/bold][/red]")
330+
console.print(f" [dim]Source: [/dim][cyan]{source}[/cyan]")
331+
console.print(f" [dim]Base URL: [/dim][cyan]{BASE_URL}[/cyan]")
332+
console.print(f" [dim]Config: [/dim][cyan]{_CONFIG_FILE}[/cyan]")
333+
334+
project_file = _find_project_config()
335+
if project_file:
336+
console.print(f" [dim]Project: [/dim][cyan]{project_file}[/cyan] [dim](muapi.json detected)[/dim]")
337+
338+
console.print()
339+
console.print("[bold]Useful links[/bold]")
340+
width = max(len(k) for k in LINKS)
341+
for name, url in LINKS.items():
342+
console.print(f" [dim]{name.ljust(width)}[/dim] [cyan]{url}[/cyan]")
343+
console.print()
344+
console.print("[dim]Jump in your browser: [/dim][cyan]muapi open <target>[/cyan]")
345+
console.print()
177346

178347

179348
@app.command("whoami")
180349
def whoami():
181-
"""Show the currently configured API key (masked)."""
182-
key = get_api_key()
183-
if not key:
184-
error_exit("No API key configured. Run: muapi auth configure", exitcodes.AUTH_ERROR)
185-
masked = key[:8] + "..." + key[-4:]
186-
out.print(f"API key: [bold]{masked}[/bold]")
350+
"""Alias for [bold]muapi auth status[/bold]."""
351+
status()
187352

188353

189354
@app.command("logout")

muapi/commands/init_cmd.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""muapi init — create a muapi.json project config in the current directory."""
2+
import json
3+
from pathlib import Path
4+
5+
import typer
6+
7+
from ..utils import console, error_exit
8+
9+
_PROJECT_FILE = "muapi.json"
10+
_SCHEMA_URL = "https://muapi.ai/schema/cli.json"
11+
12+
_DEFAULT_CONFIG = {
13+
"$schema": _SCHEMA_URL,
14+
"defaultModel": "flux-dev-image",
15+
"outputDir": "muapi-output",
16+
"aliases": {},
17+
}
18+
19+
20+
def init(
21+
yes: bool = typer.Option(False, "-y", "--yes", help="Skip prompts and write defaults"),
22+
force: bool = typer.Option(False, "-f", "--force", help="Overwrite an existing muapi.json"),
23+
):
24+
"""Create a muapi.json project config with a defaultModel and alias stubs.
25+
26+
\b
27+
Examples:
28+
muapi init # interactive
29+
muapi init -y # write defaults silently
30+
muapi init -y -f # overwrite existing
31+
"""
32+
target = Path.cwd() / _PROJECT_FILE
33+
34+
if target.exists() and not force:
35+
error_exit(
36+
f"{_PROJECT_FILE} already exists. Use --force to overwrite.",
37+
)
38+
39+
config = dict(_DEFAULT_CONFIG)
40+
41+
if not yes:
42+
from rich.prompt import Prompt
43+
default_model = Prompt.ask(
44+
"Default model",
45+
default=config["defaultModel"],
46+
console=console,
47+
)
48+
output_dir = Prompt.ask(
49+
"Output directory",
50+
default=config["outputDir"],
51+
console=console,
52+
)
53+
config["defaultModel"] = default_model
54+
config["outputDir"] = output_dir
55+
56+
target.write_text(json.dumps(config, indent=2) + "\n")
57+
console.print(f"[green]Wrote {_PROJECT_FILE}[/green]")
58+
console.print()
59+
console.print("Now try:")
60+
console.print(f" [cyan]muapi run -p \"a serene mountain lake at sunrise\"[/cyan]")
61+
console.print(f" Add aliases by editing [bold]{_PROJECT_FILE}[/bold]")
62+
console.print()

muapi/commands/open_cmd.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""muapi open — open muapi.ai pages in your browser."""
2+
import webbrowser
3+
from typing import Optional
4+
5+
import typer
6+
7+
from ..utils import console, error_exit
8+
9+
_TARGETS: dict[str, str] = {
10+
"dashboard": "https://muapi.ai/dashboard",
11+
"access-keys": "https://muapi.ai/access-keys",
12+
"models": "https://muapi.ai/models",
13+
"docs": "https://muapi.ai/docs",
14+
"pricing": "https://muapi.ai/pricing",
15+
"api": "https://api.muapi.ai/docs",
16+
"discord": "https://discord.gg/muapi",
17+
}
18+
19+
20+
def open_page(
21+
target: Optional[str] = typer.Argument(
22+
None,
23+
help="Page to open: " + ", ".join(_TARGETS.keys()),
24+
),
25+
):
26+
"""Open a muapi.ai page in your browser.
27+
28+
\b
29+
Examples:
30+
muapi open # opens dashboard
31+
muapi open access-keys # key management
32+
muapi open models # model catalog
33+
muapi open docs # API docs
34+
"""
35+
if not target:
36+
target = "dashboard"
37+
38+
url = _TARGETS.get(target.lower())
39+
if not url:
40+
valid = ", ".join(_TARGETS.keys())
41+
error_exit(f"Unknown target '{target}'. Valid: {valid}")
42+
43+
console.print(f"Opening [cyan]{url}[/cyan]")
44+
try:
45+
webbrowser.open(url)
46+
except Exception as e:
47+
error_exit(f"Could not open browser: {e}")

muapi/config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,23 @@ def get_all_settings() -> dict:
9696
return {}
9797

9898

99+
def get_key_info() -> tuple[Optional[str], str]:
100+
"""Return (api_key, source_description) for display in status/whoami."""
101+
if key := os.environ.get("MUAPI_API_KEY"):
102+
return key, "env:MUAPI_API_KEY"
103+
ok, val = _try_keyring()
104+
if ok and val:
105+
return val, "keychain"
106+
if _CONFIG_FILE.exists():
107+
try:
108+
data = json.loads(_CONFIG_FILE.read_text())
109+
if key := data.get("api_key"):
110+
return key, f"file:{_CONFIG_FILE}"
111+
except Exception:
112+
pass
113+
return None, "not set"
114+
115+
99116
def delete_api_key() -> None:
100117
ok, _ = _try_keyring()
101118
if ok:

muapi/main.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from rich import print as rprint
66

77
from . import __version__
8-
from .commands import auth, account, audio, config_cmd, docs, edit, enhance, image, keys, models, predict, run, upload, video, workflow
8+
from .commands import auth, account, audio, config_cmd, docs, edit, enhance, image, init_cmd, keys, models, open_cmd, predict, run, upload, video, workflow
99
from .commands import mcp_server
1010
from .dynamic_help import maybe_handle_run_help
1111

@@ -44,6 +44,18 @@
4444
app.add_typer(docs.app, name="docs", help="Access the muapi.ai API documentation.")
4545
app.add_typer(mcp_server.app, name="mcp", help="Run as an MCP server for AI agent integration.")
4646

47+
app.command(
48+
"init",
49+
help="Create a muapi.json project config with defaultModel and alias stubs.",
50+
context_settings={"help_option_names": ["-h", "--help"]},
51+
)(init_cmd.init)
52+
53+
app.command(
54+
"open",
55+
help="Open a muapi.ai page in your browser (dashboard, models, docs, access-keys…).",
56+
context_settings={"help_option_names": ["-h", "--help"]},
57+
)(open_cmd.open_page)
58+
4759

4860
@app.command("version")
4961
def version(

0 commit comments

Comments
 (0)