xelta-cli 0.1.0__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.
xelta/__init__.py ADDED
File without changes
xelta/auth.py ADDED
@@ -0,0 +1,28 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ CONFIG_DIR = Path.home() / ".xelta"
5
+ CONFIG_FILE = CONFIG_DIR / "config.json"
6
+
7
+
8
+ def save_token(token: str) -> None:
9
+ CONFIG_DIR.mkdir(exist_ok=True)
10
+ CONFIG_FILE.write_text(json.dumps({"jwt": token}))
11
+ try:
12
+ CONFIG_FILE.chmod(0o600)
13
+ except Exception:
14
+ pass
15
+
16
+
17
+ def load_token() -> str | None:
18
+ if not CONFIG_FILE.exists():
19
+ return None
20
+ try:
21
+ return json.loads(CONFIG_FILE.read_text()).get("jwt")
22
+ except Exception:
23
+ return None
24
+
25
+
26
+ def delete_token() -> None:
27
+ if CONFIG_FILE.exists():
28
+ CONFIG_FILE.unlink()
xelta/cli.py ADDED
@@ -0,0 +1,102 @@
1
+ import typer
2
+ from rich.console import Console
3
+ from xelta.auth import save_token, delete_token, load_token
4
+ from xelta.commands.status import status_cmd
5
+ from xelta.commands.balance import balance_cmd
6
+ from xelta.commands.image import image_cmd
7
+ from xelta.commands.mixboard import mixboard_cmd
8
+ from xelta.commands.street_ad import street_ad_cmd
9
+ from xelta.commands.reel import reel_cmd
10
+ from xelta.commands import website
11
+
12
+ console = Console()
13
+
14
+ app = typer.Typer(
15
+ name="xelta",
16
+ help="Official CLI for xelta.ai — generate images, reels, websites, and more.",
17
+ no_args_is_help=True,
18
+ rich_markup_mode="rich",
19
+ )
20
+
21
+
22
+ @app.command()
23
+ def login(
24
+ token: str = typer.Option(None, "--token", "-t", help="Paste a JWT directly instead of browser login"),
25
+ ):
26
+ """Authenticate with your Xelta account via browser."""
27
+ if token:
28
+ save_token(token.strip())
29
+ console.print("[green]Logged in successfully.[/green]")
30
+ return
31
+
32
+ try:
33
+ from xelta.oauth import browser_login
34
+ console.print("[bold]Opening browser for Xelta login...[/bold]")
35
+ jwt = browser_login()
36
+ save_token(jwt)
37
+ console.print("[green]Logged in successfully.[/green]")
38
+ except Exception as e:
39
+ console.print(f"[red]Login failed:[/red] {e}")
40
+ raise typer.Exit(1)
41
+
42
+
43
+ @app.command()
44
+ def logout():
45
+ """Remove stored credentials."""
46
+ delete_token()
47
+ console.print("Logged out.")
48
+
49
+
50
+ @app.command()
51
+ def status(debug: bool = typer.Option(False, "--debug", help="Show token prefix and raw API error")):
52
+ """Check authentication status and API connectivity."""
53
+ status_cmd(debug=debug)
54
+
55
+
56
+ @app.command()
57
+ def balance():
58
+ """Check your Xelta credit balance."""
59
+ balance_cmd()
60
+
61
+
62
+ @app.command()
63
+ def image(
64
+ prompt: str = typer.Argument(..., help="Description of the image to generate"),
65
+ model: str = typer.Option(None, "--model", "-m", help="Model ID (skip interactive picker)"),
66
+ size: str = typer.Option(None, "--size", "-s", help="Resolution: 1080p, 2K, or 4K"),
67
+ no_optimize: bool = typer.Option(False, "--no-optimize", help="Disable prompt enhancement"),
68
+ ):
69
+ """Generate an image from a text prompt."""
70
+ image_cmd(prompt=prompt, model=model, size=size, no_optimize=no_optimize)
71
+
72
+
73
+ @app.command()
74
+ def mixboard(
75
+ prompt: str = typer.Argument(..., help="Creative prompt for the image grid"),
76
+ num: int = typer.Option(8, "--num", "-n", help="Number of images"),
77
+ ):
78
+ """Generate a mixboard — a grid of AI images from a prompt."""
79
+ mixboard_cmd(prompt=prompt, num=num)
80
+
81
+
82
+ @app.command(name="street-ad")
83
+ def street_ad(
84
+ brand: str = typer.Argument(..., help="Brand name to feature"),
85
+ style: str = typer.Option("auto", "--style", help="Art style: auto, graffiti, stencil, wildstyle"),
86
+ surface: str = typer.Option("brick", "--surface", help="Surface: brick, wall, concrete"),
87
+ logo: str = typer.Option(None, "--logo", help="Path or URL to logo image"),
88
+ ):
89
+ """Generate a street art / graffiti-style ad for a brand."""
90
+ street_ad_cmd(brand=brand, style=style, surface=surface, logo=logo)
91
+
92
+
93
+ @app.command()
94
+ def reel(
95
+ prompt: str = typer.Argument(..., help="Description of the reel to generate"),
96
+ image: str = typer.Option(None, "--image", help="Path or URL to an image to include"),
97
+ ):
98
+ """Generate a short AI video reel from a text prompt."""
99
+ reel_cmd(prompt=prompt, image=image)
100
+
101
+
102
+ app.add_typer(website.app, name="website")
File without changes
@@ -0,0 +1,26 @@
1
+ import asyncio
2
+ import httpx
3
+ import typer
4
+ from rich.console import Console
5
+ from xelta.config import LIPSYNC_BASE_URL, cookie_headers
6
+
7
+ console = Console()
8
+
9
+
10
+ def balance_cmd():
11
+ """Check your Xelta credit balance."""
12
+ asyncio.run(_balance())
13
+
14
+
15
+ async def _balance():
16
+ async with httpx.AsyncClient() as client:
17
+ r = await client.get(
18
+ f"{LIPSYNC_BASE_URL}/api/v1/credits/balance",
19
+ headers=cookie_headers(),
20
+ timeout=10,
21
+ )
22
+ r.raise_for_status()
23
+ data = r.json()
24
+ bal = data.get("balance", 0)
25
+ cur = data.get("currency", "credits")
26
+ console.print(f"[bold green]Balance:[/bold green] {bal} {cur}")
@@ -0,0 +1,111 @@
1
+ import asyncio
2
+ from typing import Optional
3
+ import httpx
4
+ import typer
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+ from xelta.config import IMAGE_GEN_BASE_URL, LIPSYNC_BASE_URL, auth_headers, cookie_headers
8
+
9
+ console = Console()
10
+
11
+
12
+ def image_cmd(
13
+ prompt: str = typer.Argument(..., help="Description of the image to generate"),
14
+ model: Optional[str] = typer.Option(None, "--model", "-m", help="Model ID (skip interactive picker)"),
15
+ size: Optional[str] = typer.Option(None, "--size", "-s", help="Resolution: 1080p, 2K, or 4K"),
16
+ no_optimize: bool = typer.Option(False, "--no-optimize", help="Disable prompt enhancement"),
17
+ ):
18
+ """Generate an image from a text prompt."""
19
+ asyncio.run(_image(prompt, model, size, not no_optimize))
20
+
21
+
22
+ async def _image(prompt: str, model_id: str | None, size: str | None, optimize: bool):
23
+ async with httpx.AsyncClient() as client:
24
+ # Check credits first
25
+ r = await client.get(
26
+ f"{LIPSYNC_BASE_URL}/api/v1/credits/balance",
27
+ headers=cookie_headers(),
28
+ timeout=10,
29
+ )
30
+ r.raise_for_status()
31
+ if r.json().get("balance", 0) <= 0:
32
+ console.print("[red]No credits remaining.[/red] Top up at https://www.xelta.ai")
33
+ raise typer.Exit(1)
34
+
35
+ # Step 1: Pick model
36
+ if not model_id:
37
+ r = await client.get(
38
+ f"{IMAGE_GEN_BASE_URL}/api/models/image_generation/all",
39
+ headers=auth_headers(),
40
+ timeout=15,
41
+ )
42
+ r.raise_for_status()
43
+ models = r.json().get("data", [])
44
+ if not models:
45
+ console.print("[red]No models available.[/red]")
46
+ raise typer.Exit(1)
47
+
48
+ table = Table(title="Available Models", show_lines=True)
49
+ table.add_column("#", style="bold cyan", width=4)
50
+ table.add_column("Name", style="bold")
51
+ table.add_column("ID")
52
+ table.add_column("Credits", justify="right")
53
+ table.add_column("Est. Time")
54
+ for i, m in enumerate(models, 1):
55
+ table.add_row(
56
+ str(i),
57
+ m.get("displayName", ""),
58
+ m.get("modelId", ""),
59
+ str(m.get("creditCost", "?")),
60
+ str(m.get("estimatedTime", "?")),
61
+ )
62
+ console.print(table)
63
+ choice = typer.prompt("Pick a model number")
64
+ try:
65
+ model_id = models[int(choice) - 1]["modelId"]
66
+ except (ValueError, IndexError):
67
+ console.print("[red]Invalid choice.[/red]")
68
+ raise typer.Exit(1)
69
+
70
+ # Step 2: Pick size
71
+ if not size:
72
+ console.print("\n[bold]Choose a resolution:[/bold]")
73
+ console.print(" 1. 1080p — standard HD")
74
+ console.print(" 2. 2K — sharp detail")
75
+ console.print(" 3. 4K — ultra-high resolution (more credits)")
76
+ size_map = {"1": "1080p", "2": "2K", "3": "4K"}
77
+ s = typer.prompt("Pick a size (1/2/3)", default="2")
78
+ size = size_map.get(s, "2K")
79
+
80
+ # Step 3: Generate
81
+ console.print(f"\n[dim]Generating with model [bold]{model_id}[/bold] at {size}...[/dim]")
82
+ payload = {
83
+ "prompt": prompt,
84
+ "size": size,
85
+ "optimize_prompt_options": {"mode": "standard" if optimize else "none"},
86
+ }
87
+ r = await client.post(
88
+ f"{IMAGE_GEN_BASE_URL}/api/models/{model_id}/generate",
89
+ headers={**auth_headers(), "Content-Type": "application/json"},
90
+ json=payload,
91
+ timeout=120,
92
+ )
93
+ r.raise_for_status()
94
+ resp = r.json()
95
+
96
+ result = resp.get("data", {}).get("result", {})
97
+ image_url = result.get("content", "")
98
+ if not image_url:
99
+ console.print(f"[red]Generation failed:[/red] {resp}")
100
+ raise typer.Exit(1)
101
+
102
+ meta = resp.get("metadata", {})
103
+ res_meta = result.get("metadata", {})
104
+ console.print(f"\n[bold green]Image ready![/bold green]")
105
+ console.print(f" Model: {meta.get('modelName', model_id)}")
106
+ console.print(f" Resolution: {res_meta.get('size', size)}")
107
+ console.print(f" Time: {meta.get('executionTime', '?')}")
108
+ console.print(f" [bold]URL:[/bold] {image_url}")
109
+ bal = resp.get("creditTransaction", {}).get("newBalance")
110
+ if bal is not None:
111
+ console.print(f" Balance: {bal} credits remaining")
@@ -0,0 +1,89 @@
1
+ import asyncio
2
+ import httpx
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.progress import Progress, SpinnerColumn, TextColumn
6
+ from xelta.config import MIXBOARD_BASE_URL, LIPSYNC_BASE_URL, auth_headers, cookie_headers
7
+ from xelta.auth import load_token
8
+ import base64, json
9
+
10
+ console = Console()
11
+
12
+
13
+ def _user_id() -> str:
14
+ token = load_token() or ""
15
+ try:
16
+ payload = token.split(".")[1]
17
+ payload += "=" * (4 - len(payload) % 4)
18
+ return str(json.loads(base64.urlsafe_b64decode(payload)).get("id", ""))
19
+ except Exception:
20
+ return ""
21
+
22
+
23
+ def mixboard_cmd(
24
+ prompt: str = typer.Argument(..., help="Creative prompt for the image grid"),
25
+ num: int = typer.Option(8, "--num", "-n", help="Number of images (default: 8)"),
26
+ ):
27
+ """Generate a mixboard — a grid of AI images from a prompt."""
28
+ asyncio.run(_mixboard(prompt, num))
29
+
30
+
31
+ async def _mixboard(prompt: str, num_images: int):
32
+ async with httpx.AsyncClient(timeout=60) as client:
33
+ r = await client.get(
34
+ f"{LIPSYNC_BASE_URL}/api/v1/credits/balance",
35
+ headers=cookie_headers(),
36
+ timeout=10,
37
+ )
38
+ r.raise_for_status()
39
+ if r.json().get("balance", 0) <= 0:
40
+ console.print("[red]No credits remaining.[/red] Top up at https://www.xelta.ai")
41
+ raise typer.Exit(1)
42
+
43
+ r = await client.post(
44
+ f"{MIXBOARD_BASE_URL}/api/mixboards/",
45
+ json={"command": prompt, "user_id": _user_id(), "num_images": num_images},
46
+ headers=auth_headers(),
47
+ timeout=30,
48
+ )
49
+ r.raise_for_status()
50
+ board_id = r.json().get("id")
51
+ if not board_id:
52
+ console.print(f"[red]Unexpected response:[/red] {r.json()}")
53
+ raise typer.Exit(1)
54
+
55
+ console.print(f"[dim]Mixboard created (id: {board_id}). Generating {num_images} images...[/dim]")
56
+
57
+ progress_url = f"{MIXBOARD_BASE_URL}/api/mixboards/{board_id}/progress"
58
+ timed_out = True
59
+ for _ in range(60):
60
+ await asyncio.sleep(5)
61
+ pr = await client.get(progress_url, headers=auth_headers(), timeout=15)
62
+ if pr.status_code == 200:
63
+ pct = pr.json().get("progress_percent", 0)
64
+ console.print(f" [dim]{pct}%[/dim]", end="\r")
65
+ if pct >= 100:
66
+ timed_out = False
67
+ break
68
+
69
+ if timed_out:
70
+ console.print(f"[yellow]Timed out.[/yellow] Check board manually: {board_id}")
71
+ raise typer.Exit(1)
72
+
73
+ board_r = await client.get(
74
+ f"{MIXBOARD_BASE_URL}/api/mixboards/{board_id}",
75
+ headers=auth_headers(),
76
+ timeout=15,
77
+ )
78
+ board_r.raise_for_status()
79
+ images = [img for img in board_r.json().get("images", []) if img.get("status") == "completed"]
80
+
81
+ if not images:
82
+ console.print(f"[yellow]No images returned yet.[/yellow] Board ID: {board_id}")
83
+ raise typer.Exit(1)
84
+
85
+ console.print(f"\n[bold green]Mixboard ready![/bold green] {len(images)} images:")
86
+ for i, img in enumerate(images, 1):
87
+ url = img.get("image_url", "")
88
+ if url:
89
+ console.print(f" {i}. {url}")
xelta/commands/reel.py ADDED
@@ -0,0 +1,116 @@
1
+ import asyncio
2
+ from typing import Optional
3
+ import httpx
4
+ import typer
5
+ from rich.console import Console
6
+ from xelta.config import REEL_BASE_URL, LIPSYNC_BASE_URL, auth_headers, cookie_headers
7
+
8
+ console = Console()
9
+
10
+
11
+ def reel_cmd(
12
+ prompt: str = typer.Argument(..., help="Description of the reel to generate"),
13
+ image: Optional[str] = typer.Option(None, "--image", help="Path or URL to an image to include"),
14
+ ):
15
+ """Generate a short AI video reel from a text prompt."""
16
+ asyncio.run(_reel(prompt, image or ""))
17
+
18
+
19
+ async def _reel(prompt: str, image_path: str):
20
+ async with httpx.AsyncClient(timeout=60) as client:
21
+ r = await client.get(
22
+ f"{LIPSYNC_BASE_URL}/api/v1/credits/balance",
23
+ headers=cookie_headers(),
24
+ timeout=10,
25
+ )
26
+ r.raise_for_status()
27
+ if r.json().get("balance", 0) <= 0:
28
+ console.print("[red]No credits remaining.[/red] Top up at https://www.xelta.ai")
29
+ raise typer.Exit(1)
30
+
31
+ console.print(f"[dim]Submitting reel prompt...[/dim]")
32
+ r = await client.post(
33
+ f"{REEL_BASE_URL}/api/reels",
34
+ json={"prompt": prompt},
35
+ headers=auth_headers(),
36
+ timeout=30,
37
+ )
38
+ r.raise_for_status()
39
+ reel_id = r.json().get("id")
40
+ if not reel_id:
41
+ console.print(f"[red]Unexpected response:[/red] {r.json()}")
42
+ raise typer.Exit(1)
43
+
44
+ if image_path:
45
+ img_bytes, img_name = await _load_file(client, image_path)
46
+ ext = img_name.rsplit(".", 1)[-1].lower() if "." in img_name else "jpg"
47
+ await client.post(
48
+ f"{REEL_BASE_URL}/api/reels/{reel_id}/upload-image",
49
+ files={"image": (img_name, img_bytes, f"image/{ext}")},
50
+ headers=auth_headers(),
51
+ timeout=30,
52
+ )
53
+
54
+ gen = await client.post(
55
+ f"{REEL_BASE_URL}/api/reels/{reel_id}/generate",
56
+ headers=auth_headers(),
57
+ timeout=30,
58
+ )
59
+ gen.raise_for_status()
60
+ console.print(f"[dim]Rendering... (this can take up to 6 minutes)[/dim]")
61
+
62
+ for _ in range(72):
63
+ await asyncio.sleep(5)
64
+ st = await client.get(
65
+ f"{REEL_BASE_URL}/api/reels/{reel_id}",
66
+ headers=auth_headers(),
67
+ timeout=15,
68
+ )
69
+ if st.status_code == 200:
70
+ data = st.json()
71
+ status = str(data.get("status", "")).lower()
72
+ progress = data.get("progress_percent", 0)
73
+ console.print(f" [dim]{progress}%[/dim]", end="\r")
74
+ if status == "completed" or progress >= 100:
75
+ video_url = data.get("video_url", "")
76
+ if video_url:
77
+ _print_result(reel_id, video_url)
78
+ return
79
+ break
80
+ if status in ("failed", "error"):
81
+ console.print(f"[red]Failed:[/red] {data.get('error_message') or data}")
82
+ raise typer.Exit(1)
83
+
84
+ # Fall back to history
85
+ hist = await client.get(
86
+ f"{REEL_BASE_URL}/api/reels/history",
87
+ headers=auth_headers(),
88
+ timeout=15,
89
+ )
90
+ if hist.status_code == 200:
91
+ for item in hist.json().get("items", []):
92
+ if str(item.get("id")) == str(reel_id):
93
+ video_url = item.get("video_url", "")
94
+ if video_url:
95
+ _print_result(reel_id, video_url)
96
+ return
97
+
98
+ console.print(f"[yellow]Timed out.[/yellow] Reel ID: {reel_id}")
99
+
100
+
101
+ def _print_result(reel_id: str, video_url: str):
102
+ console.print(f"\n[bold green]Reel ready![/bold green]")
103
+ console.print(f" Video: {video_url}")
104
+ console.print(f" Reel ID: {reel_id}")
105
+
106
+
107
+ async def _load_file(client: httpx.AsyncClient, path_or_url: str) -> tuple[bytes, str]:
108
+ if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
109
+ r = await client.get(path_or_url, follow_redirects=True, timeout=60)
110
+ r.raise_for_status()
111
+ name = path_or_url.split("/")[-1].split("?")[0] or "image.jpg"
112
+ return r.content, name
113
+ import os
114
+ path = os.path.normpath(path_or_url)
115
+ with open(path, "rb") as f:
116
+ return f.read(), os.path.basename(path)
@@ -0,0 +1,61 @@
1
+ import asyncio
2
+ import base64
3
+ import json
4
+ import httpx
5
+ import typer
6
+ from rich.console import Console
7
+ from xelta.auth import load_token
8
+ from xelta.config import LIPSYNC_BASE_URL, cookie_headers
9
+
10
+ console = Console()
11
+
12
+
13
+ def status_cmd(debug: bool = typer.Option(False, "--debug", help="Show token prefix and raw API error")):
14
+ """Check authentication status and API connectivity."""
15
+ asyncio.run(_status(debug))
16
+
17
+
18
+ async def _status(debug: bool):
19
+ token = load_token()
20
+ if not token:
21
+ console.print("[red]Not logged in.[/red] Run `xelta login` to authenticate.")
22
+ raise typer.Exit(1)
23
+
24
+ user_id, email = _decode_jwt(token)
25
+ is_jwt = token.startswith("eyJ")
26
+
27
+ console.print("[bold]Xelta CLI — connection status[/bold]")
28
+ console.print(f" Auth: [green]logged in[/green]")
29
+ console.print(f" Token: {'JWT ✓' if is_jwt else '[yellow]not a JWT (starts with: ' + token[:12] + '...)[/yellow]'}")
30
+ if user_id:
31
+ console.print(f" User ID: {user_id}")
32
+ if email:
33
+ console.print(f" Email: {email}")
34
+
35
+ try:
36
+ async with httpx.AsyncClient() as client:
37
+ r = await client.get(
38
+ f"{LIPSYNC_BASE_URL}/api/v1/credits/balance",
39
+ headers=cookie_headers(),
40
+ timeout=10,
41
+ )
42
+ if debug:
43
+ console.print(f" [dim]Status: {r.status_code} | Body: {r.text[:200]}[/dim]")
44
+ r.raise_for_status()
45
+ bal = r.json().get("balance", 0)
46
+ console.print(f" Balance: {bal} credits")
47
+ console.print(f" API: [green]reachable[/green]")
48
+ except Exception as e:
49
+ console.print(f" API: [red]unreachable[/red]")
50
+ if debug:
51
+ console.print(f" [dim]{e}[/dim]")
52
+
53
+
54
+ def _decode_jwt(token: str) -> tuple[str, str]:
55
+ try:
56
+ payload = token.split(".")[1]
57
+ payload += "=" * (4 - len(payload) % 4)
58
+ data = json.loads(base64.urlsafe_b64decode(payload))
59
+ return str(data.get("id") or data.get("sub") or ""), str(data.get("email") or "")
60
+ except Exception:
61
+ return "", ""
@@ -0,0 +1,86 @@
1
+ import asyncio
2
+ from typing import Optional
3
+ import httpx
4
+ import typer
5
+ from rich.console import Console
6
+ from xelta.config import STREETAD_BASE_URL, LIPSYNC_BASE_URL, auth_headers, cookie_headers
7
+
8
+ console = Console()
9
+
10
+
11
+ def street_ad_cmd(
12
+ brand: str = typer.Argument(..., help="Brand name to feature"),
13
+ style: str = typer.Option("auto", "--style", help="Art style: auto, graffiti, stencil, wildstyle"),
14
+ surface: str = typer.Option("brick", "--surface", help="Surface: brick, wall, concrete"),
15
+ logo: Optional[str] = typer.Option(None, "--logo", help="Path or URL to logo image"),
16
+ ):
17
+ """Generate a street art / graffiti-style ad for a brand."""
18
+ asyncio.run(_street_ad(brand, style, surface, logo or ""))
19
+
20
+
21
+ async def _street_ad(brand: str, style: str, surface: str, logo_path: str):
22
+ async with httpx.AsyncClient(timeout=60) as client:
23
+ r = await client.get(
24
+ f"{LIPSYNC_BASE_URL}/api/v1/credits/balance",
25
+ headers=cookie_headers(),
26
+ timeout=10,
27
+ )
28
+ r.raise_for_status()
29
+ if r.json().get("balance", 0) <= 0:
30
+ console.print("[red]No credits remaining.[/red] Top up at https://www.xelta.ai")
31
+ raise typer.Exit(1)
32
+
33
+ form: dict = {"brand": brand, "style": style, "surface": surface}
34
+ files = None
35
+ if logo_path:
36
+ logo_bytes, logo_name = await _load_file(client, logo_path)
37
+ files = {"logo": (logo_name, logo_bytes, "image/png")}
38
+
39
+ console.print(f"[dim]Generating street ad for [bold]{brand}[/bold]...[/dim]")
40
+ r = await client.post(
41
+ f"{STREETAD_BASE_URL}/generate",
42
+ data=form,
43
+ files=files,
44
+ headers=auth_headers(),
45
+ timeout=60,
46
+ )
47
+ r.raise_for_status()
48
+ generation_id = r.json().get("generation_id")
49
+ if not generation_id:
50
+ console.print(f"[red]Unexpected response:[/red] {r.json()}")
51
+ raise typer.Exit(1)
52
+
53
+ result = await _poll(client, f"{STREETAD_BASE_URL}/status/{generation_id}")
54
+ console.print("\n[bold green]Street ad ready![/bold green]")
55
+ if result.get("output_image_url"):
56
+ console.print(f" Image: {result['output_image_url']}")
57
+ if result.get("output_video_url"):
58
+ console.print(f" Video: {result['output_video_url']}")
59
+
60
+
61
+ async def _load_file(client: httpx.AsyncClient, path_or_url: str) -> tuple[bytes, str]:
62
+ if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
63
+ r = await client.get(path_or_url, follow_redirects=True, timeout=60)
64
+ r.raise_for_status()
65
+ name = path_or_url.split("/")[-1].split("?")[0] or "logo.png"
66
+ return r.content, name
67
+ import os
68
+ path = os.path.normpath(path_or_url)
69
+ with open(path, "rb") as f:
70
+ return f.read(), os.path.basename(path)
71
+
72
+
73
+ async def _poll(client: httpx.AsyncClient, url: str, interval: int = 5, timeout: int = 300) -> dict:
74
+ elapsed = 0
75
+ while elapsed < timeout:
76
+ await asyncio.sleep(interval)
77
+ elapsed += interval
78
+ r = await client.get(url, headers=auth_headers(), timeout=15)
79
+ r.raise_for_status()
80
+ data = r.json()
81
+ status = str(data.get("status") or data.get("state") or "").lower()
82
+ if status in ("completed", "done", "success", "finished"):
83
+ return data
84
+ if status in ("failed", "error", "cancelled"):
85
+ raise RuntimeError(f"Job failed: {data.get('error') or data}")
86
+ raise TimeoutError(f"Job timed out after {timeout}s")
@@ -0,0 +1,159 @@
1
+ import asyncio
2
+ from typing import Optional
3
+ import httpx
4
+ import typer
5
+ from rich.console import Console
6
+ from xelta.config import WEBBUILDER_BASE_URL, LIPSYNC_BASE_URL, auth_headers, cookie_headers
7
+
8
+ console = Console()
9
+ app = typer.Typer(help="AI website builder commands")
10
+
11
+
12
+ @app.command("build")
13
+ def build_cmd(
14
+ prompt: str = typer.Argument(..., help="Description of the website to generate"),
15
+ industry: str = typer.Option(..., "--industry", "-i", help="Industry: technology, healthcare, fashion, etc."),
16
+ pages: str = typer.Option("Home, About, Contact", "--pages", "-p", help="Comma-separated page list"),
17
+ palette: Optional[str] = typer.Option(None, "--palette", help="Color palette name or hex values"),
18
+ logo: Optional[str] = typer.Option(None, "--logo", help="Path or URL to logo image"),
19
+ deploy: bool = typer.Option(False, "--deploy", help="Deploy live to Vercel after generation"),
20
+ ):
21
+ """Generate an AI website using the Pomeli Website Builder."""
22
+ asyncio.run(_build(prompt, industry, pages, palette or "", logo or "", deploy))
23
+
24
+
25
+ @app.command("history")
26
+ def history_cmd(
27
+ limit: int = typer.Option(5, "--limit", "-n", help="Number of recent websites to show"),
28
+ ):
29
+ """List recently generated websites."""
30
+ asyncio.run(_history(limit))
31
+
32
+
33
+ async def _build(prompt: str, industry: str, pages: str, palette: str, logo_path: str, deploy: bool):
34
+ async with httpx.AsyncClient(timeout=60) as client:
35
+ r = await client.get(
36
+ f"{LIPSYNC_BASE_URL}/api/v1/credits/balance",
37
+ headers=cookie_headers(),
38
+ timeout=10,
39
+ )
40
+ r.raise_for_status()
41
+ if r.json().get("balance", 0) <= 0:
42
+ console.print("[red]No credits remaining.[/red] Top up at https://www.xelta.ai")
43
+ raise typer.Exit(1)
44
+
45
+ page_list = [p.strip() for p in pages.split(",") if p.strip()]
46
+ multipart = [
47
+ ("prompt", (None, prompt)),
48
+ ("industry", (None, industry)),
49
+ ]
50
+ for page in page_list:
51
+ multipart.append(("pages", (None, page)))
52
+ if palette:
53
+ multipart.append(("palette", (None, palette)))
54
+ if logo_path:
55
+ logo_bytes, logo_name = await _load_file(client, logo_path)
56
+ multipart.append(("logo", (logo_name, logo_bytes, "image/png")))
57
+
58
+ console.print(f"[dim]Generating website...[/dim]")
59
+ r = await client.post(
60
+ f"{WEBBUILDER_BASE_URL}/generate",
61
+ files=multipart,
62
+ headers=auth_headers(),
63
+ timeout=60,
64
+ )
65
+ r.raise_for_status()
66
+ result = r.json()
67
+ job_id = result.get("job_id")
68
+ website_id = result.get("website_id")
69
+ if not job_id:
70
+ console.print(f"[red]Unexpected response:[/red] {result}")
71
+ raise typer.Exit(1)
72
+
73
+ for _ in range(60):
74
+ await asyncio.sleep(5)
75
+ st = await client.get(
76
+ f"{WEBBUILDER_BASE_URL}/job-status/{job_id}",
77
+ headers=auth_headers(),
78
+ timeout=15,
79
+ )
80
+ if st.status_code == 200:
81
+ st_data = st.json()
82
+ status = str(st_data.get("status", "")).lower()
83
+ website_id = st_data.get("website_id", website_id)
84
+ if status == "completed":
85
+ raw_preview = st_data.get("preview_url", "")
86
+ preview_url = (
87
+ f"{WEBBUILDER_BASE_URL}{raw_preview}"
88
+ if raw_preview and not raw_preview.startswith("http")
89
+ else raw_preview or f"{WEBBUILDER_BASE_URL}/preview/{website_id}/index.html"
90
+ )
91
+ if deploy:
92
+ dep = await client.post(
93
+ f"{WEBBUILDER_BASE_URL}/deploy",
94
+ json={"website_id": website_id},
95
+ headers=auth_headers(),
96
+ timeout=60,
97
+ )
98
+ if dep.status_code == 200:
99
+ dep_data = dep.json()
100
+ live_url = (
101
+ dep_data.get("final_url")
102
+ or dep_data.get("url")
103
+ or dep_data.get("deployment_url")
104
+ or dep_data.get("vercel_url", "")
105
+ )
106
+ console.print("\n[bold green]Website built and deployed![/bold green]")
107
+ if live_url:
108
+ console.print(f" Live: {live_url}")
109
+ console.print(f" Preview: {preview_url}")
110
+ console.print(f" Website ID: {website_id}")
111
+ return
112
+ console.print("\n[bold green]Website generated![/bold green]")
113
+ console.print(f" Preview: {preview_url}")
114
+ console.print(f" Website ID: {website_id}")
115
+ return
116
+ if status in ("failed", "error"):
117
+ console.print(f"[red]Generation failed:[/red] {st_data}")
118
+ raise typer.Exit(1)
119
+
120
+ console.print(f"[yellow]Timed out.[/yellow] Website ID: {website_id}")
121
+
122
+
123
+ async def _history(limit: int):
124
+ async with httpx.AsyncClient(timeout=30) as client:
125
+ r = await client.get(
126
+ f"{WEBBUILDER_BASE_URL}/history",
127
+ headers=auth_headers(),
128
+ timeout=15,
129
+ )
130
+ r.raise_for_status()
131
+ data = r.json()
132
+ items = data if isinstance(data, list) else data.get("items", [])
133
+ if not items:
134
+ console.print("No websites found.")
135
+ return
136
+ console.print(f"[bold]Last {min(limit, len(items))} website(s):[/bold]\n")
137
+ for site in items[:limit]:
138
+ name = site.get("site_name") or site.get("website_id", "Unnamed")
139
+ status = site.get("status", "")
140
+ console.print(f" [bold]{name}[/bold] [{status}]")
141
+ if site.get("final_url"):
142
+ console.print(f" Live: {site['final_url']}")
143
+ if site.get("preview_url"):
144
+ preview = site["preview_url"]
145
+ if not preview.startswith("http"):
146
+ preview = f"{WEBBUILDER_BASE_URL}{preview}"
147
+ console.print(f" Preview: {preview}")
148
+
149
+
150
+ async def _load_file(client: httpx.AsyncClient, path_or_url: str) -> tuple[bytes, str]:
151
+ if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
152
+ r = await client.get(path_or_url, follow_redirects=True, timeout=60)
153
+ r.raise_for_status()
154
+ name = path_or_url.split("/")[-1].split("?")[0] or "logo.png"
155
+ return r.content, name
156
+ import os
157
+ path = os.path.normpath(path_or_url)
158
+ with open(path, "rb") as f:
159
+ return f.read(), os.path.basename(path)
xelta/config.py ADDED
@@ -0,0 +1,30 @@
1
+ from xelta.auth import load_token
2
+
3
+ LIPSYNC_BASE_URL = "https://lipsyncus.xelta.ai"
4
+ MIXBOARD_BASE_URL = "https://mixboard.xelta.ai"
5
+ STREETAD_BASE_URL = "https://grf.xelta.ai"
6
+ WEBBUILDER_BASE_URL = "https://webgen.xelta.ai"
7
+ REEL_BASE_URL = "https://reel.xelta.ai"
8
+ IMAGE_GEN_BASE_URL = "https://vygxif9kfe.execute-api.ap-south-1.amazonaws.com"
9
+
10
+ _BROWSER = {
11
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
12
+ "Accept": "application/json",
13
+ "Origin": "https://www.xelta.ai",
14
+ "Referer": "https://www.xelta.ai/",
15
+ }
16
+
17
+
18
+ def auth_headers() -> dict:
19
+ token = load_token()
20
+ if not token:
21
+ raise RuntimeError("Not logged in. Run `xelta login` first.")
22
+ return {**_BROWSER, "Authorization": f"Bearer {token}"}
23
+
24
+
25
+ def cookie_headers() -> dict:
26
+ """Cookie-based auth used by lipsync, comic, and similar services."""
27
+ token = load_token()
28
+ if not token:
29
+ raise RuntimeError("Not logged in. Run `xelta login` first.")
30
+ return {**_BROWSER, "Cookie": f"token={token}"}
xelta/oauth.py ADDED
@@ -0,0 +1,113 @@
1
+ import base64
2
+ import hashlib
3
+ import http.server
4
+ import secrets
5
+ import webbrowser
6
+ from urllib.parse import parse_qs, urlencode, urlparse
7
+
8
+ import httpx
9
+
10
+ XELTA_AUTHORIZE_URL = "https://api.xelta.ai/api/auth/oauth/authorize"
11
+ XELTA_TOKEN_URL = "https://api.xelta.ai/api/auth/oauth/token"
12
+ CLI_CLIENT_ID = "xelta-cli"
13
+ CALLBACK_PORT = 8787
14
+ REDIRECT_URI = f"http://localhost:{CALLBACK_PORT}/callback"
15
+
16
+ _SUCCESS_HTML = b"""<!DOCTYPE html>
17
+ <html>
18
+ <head><meta charset="utf-8"><title>Xelta CLI</title>
19
+ <style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;
20
+ justify-content:center;min-height:100vh;margin:0;background:#f5f5f7}
21
+ .card{text-align:center;padding:48px 40px;background:#fff;border-radius:16px;
22
+ box-shadow:0 4px 24px rgba(0,0,0,.08);max-width:380px}
23
+ h2{color:#111;margin:0 0 12px}p{color:#555;margin:0}</style></head>
24
+ <body><div class="card">
25
+ <div style="font-size:48px;margin-bottom:16px">&#10003;</div>
26
+ <h2>Logged in to Xelta!</h2>
27
+ <p>You can close this tab and return to the terminal.</p>
28
+ </div></body></html>"""
29
+
30
+ _ERROR_HTML = b"""<!DOCTYPE html>
31
+ <html><head><meta charset="utf-8"><title>Xelta CLI</title></head>
32
+ <body style="font-family:sans-serif;text-align:center;padding:60px">
33
+ <h2>Login failed</h2><p>Return to the terminal for details.</p>
34
+ </body></html>"""
35
+
36
+
37
+ def _pkce_pair() -> tuple[str, str]:
38
+ verifier = secrets.token_urlsafe(64)
39
+ challenge = base64.urlsafe_b64encode(
40
+ hashlib.sha256(verifier.encode()).digest()
41
+ ).rstrip(b"=").decode()
42
+ return verifier, challenge
43
+
44
+
45
+ def browser_login() -> str:
46
+ """Open the browser, complete OAuth PKCE flow, return JWT access_token."""
47
+ verifier, challenge = _pkce_pair()
48
+ state = secrets.token_urlsafe(16)
49
+
50
+ params = {
51
+ "response_type": "code",
52
+ "client_id": CLI_CLIENT_ID,
53
+ "redirect_uri": REDIRECT_URI,
54
+ "state": state,
55
+ "code_challenge": challenge,
56
+ "code_challenge_method": "S256",
57
+ }
58
+ auth_url = f"{XELTA_AUTHORIZE_URL}?{urlencode(params)}"
59
+
60
+ result: dict = {}
61
+
62
+ class _Handler(http.server.BaseHTTPRequestHandler):
63
+ def do_GET(self):
64
+ qs = parse_qs(urlparse(self.path).query)
65
+ result["code"] = qs.get("code", [None])[0]
66
+ result["state"] = qs.get("state", [None])[0]
67
+ result["error"] = qs.get("error", [None])[0]
68
+ ok = result["code"] and result["state"] == state
69
+ self.send_response(200)
70
+ self.send_header("Content-Type", "text/html")
71
+ self.end_headers()
72
+ self.wfile.write(_SUCCESS_HTML if ok else _ERROR_HTML)
73
+
74
+ def log_message(self, *args):
75
+ pass
76
+
77
+ server = http.server.HTTPServer(("localhost", CALLBACK_PORT), _Handler)
78
+ webbrowser.open(auth_url)
79
+
80
+ print(f"Browser opened. Waiting for login...")
81
+ server.handle_request()
82
+ server.server_close()
83
+
84
+ if result.get("error"):
85
+ raise RuntimeError(f"Login cancelled or failed: {result['error']}")
86
+ if result.get("state") != state:
87
+ raise RuntimeError("State mismatch — login aborted for security.")
88
+
89
+ code = result.get("code")
90
+ if not code:
91
+ raise RuntimeError("No authorization code received.")
92
+
93
+ return _exchange_code(code, verifier)
94
+
95
+
96
+ def _exchange_code(code: str, verifier: str) -> str:
97
+ with httpx.Client(timeout=15) as client:
98
+ r = client.post(
99
+ XELTA_TOKEN_URL,
100
+ data={
101
+ "grant_type": "authorization_code",
102
+ "code": code,
103
+ "client_id": CLI_CLIENT_ID,
104
+ "redirect_uri": REDIRECT_URI,
105
+ "code_verifier": verifier,
106
+ },
107
+ headers={"Accept": "application/json"},
108
+ )
109
+ r.raise_for_status()
110
+ token = r.json().get("access_token", "")
111
+ if not token:
112
+ raise RuntimeError(f"No access_token in response: {r.text}")
113
+ return token
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: xelta-cli
3
+ Version: 0.1.0
4
+ Summary: Official CLI for xelta.ai
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: typer>=0.12
8
+ Requires-Dist: rich>=13
@@ -0,0 +1,18 @@
1
+ xelta/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ xelta/auth.py,sha256=ireuAfqjkj0AoIz9lgI-9nVWA5Y-1cAw-4WFiKbncyw,609
3
+ xelta/cli.py,sha256=LiKKAVAUsfZEUpt5sv9wWOlJ5gdMVQexcQCrqeQhdh8,3429
4
+ xelta/config.py,sha256=BIG71PldUtU3R2OpUj3TUeFZ4L1nlsHawiq0uIw2x_o,1039
5
+ xelta/oauth.py,sha256=23eEOmGPIXNaDgsoc7jbHOGN7JTg1z7npSZIX8N9bkI,3906
6
+ xelta/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ xelta/commands/balance.py,sha256=ggyzjBVfNbaGzySQpW84uwULBbDB5OlDFPymvsSAytI,683
8
+ xelta/commands/image.py,sha256=KNyIpWNhQ83dbhbrDim4DypQjkhhdWG1m4TIhdBIfLQ,4563
9
+ xelta/commands/mixboard.py,sha256=1EYXZcXAnQDfNDvxvt5lDEadCCl3dnPf0f_0W7-QDCw,3238
10
+ xelta/commands/reel.py,sha256=6ts1ctpkRIgEeRzPiBjLslW2cXlcUbyiZPD0WxltamU,4378
11
+ xelta/commands/status.py,sha256=ifHVVmbHJKXCM6ODCC1ph3zQ4Dbgio5rGlG8TfCWdrg,2102
12
+ xelta/commands/street_ad.py,sha256=AAgOUZCHhh5QLibwoF_PxB-pKwUTqBrRsDpKjlvpfH0,3550
13
+ xelta/commands/website.py,sha256=bK12fnqNCry-9uhFfrBt6dR6QZBk5DOPwVQXCSrI1cg,6929
14
+ xelta_cli-0.1.0.dist-info/METADATA,sha256=VGes6GJmH_FMKstFxjCmQKwBskz2JbhGvpGQnkkU-mY,198
15
+ xelta_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
16
+ xelta_cli-0.1.0.dist-info/entry_points.txt,sha256=g52sxEdYSTdhdBxmFXClez7tW2m3vos2vTT0XbpS0UY,40
17
+ xelta_cli-0.1.0.dist-info/top_level.txt,sha256=2IxOL5iARzoQ5JLl5CkSm9x31qdiOTTdM03PivUgMHw,6
18
+ xelta_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ xelta = xelta.cli:app
@@ -0,0 +1 @@
1
+ xelta