twelveten-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: twelveten-cli
3
+ Version: 0.1.0
4
+ Summary: TwelveTen CLI — AI workflow platform from your terminal
5
+ Author-email: TwelveTen <hello@twelveten.ai>
6
+ License: MIT
7
+ Project-URL: Homepage, https://twelveten.io
8
+ Project-URL: Repository, https://github.com/TwelveTenTippers/kinship
9
+ Keywords: cli,ai,comfyui,gpu,workflow
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Multimedia :: Graphics
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: click>=8.1
22
+ Requires-Dist: rich>=13.0
23
+ Requires-Dist: prompt-toolkit>=3.0
24
+ Requires-Dist: boto3>=1.34
25
+ Requires-Dist: httpx>=0.27
26
+ Requires-Dist: pyjwt>=2.8
27
+ Requires-Dist: InquirerPy>=0.3
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "twelveten-cli"
3
+ version = "0.1.0"
4
+ description = "TwelveTen CLI — AI workflow platform from your terminal"
5
+ readme = "README.md"
6
+ license = {text = "MIT"}
7
+ requires-python = ">=3.10"
8
+ authors = [
9
+ {name = "TwelveTen", email = "hello@twelveten.ai"}
10
+ ]
11
+ keywords = ["cli", "ai", "comfyui", "gpu", "workflow"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "Topic :: Multimedia :: Graphics",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ ]
23
+ dependencies = [
24
+ "click>=8.1",
25
+ "rich>=13.0",
26
+ "prompt-toolkit>=3.0",
27
+ "boto3>=1.34",
28
+ "httpx>=0.27",
29
+ "pyjwt>=2.8",
30
+ "InquirerPy>=0.3",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://twelveten.io"
35
+ Repository = "https://github.com/TwelveTenTippers/kinship"
36
+
37
+ [project.scripts]
38
+ "1210" = "twelveten_cli.main:cli"
39
+
40
+ [build-system]
41
+ requires = ["setuptools>=68"]
42
+ build-backend = "setuptools.build_meta"
43
+
44
+ [tool.setuptools.packages.find]
45
+ include = ["twelveten_cli*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """TwelveTen CLI — AI workflow platform from your terminal."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+ """API client — authenticated requests to TwelveTen API Gateway."""
3
+
4
+ import httpx
5
+
6
+ from .config import API_BASE_URL, CLOUDFRONT_URL
7
+ from .auth import get_id_token
8
+
9
+
10
+ def _headers() -> dict:
11
+ token = get_id_token()
12
+ if not token:
13
+ raise Exception("Not authenticated. Run: 1210 login")
14
+ return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
15
+
16
+
17
+ def _get(path: str) -> dict:
18
+ r = httpx.get(f"{API_BASE_URL}{path}", headers=_headers(), timeout=30)
19
+ if r.status_code != 200:
20
+ try:
21
+ data = r.json()
22
+ msg = data.get("message") or data.get("error") or r.text
23
+ except Exception:
24
+ msg = r.text[:200]
25
+ raise Exception(msg)
26
+ return r.json()
27
+
28
+
29
+ def _post(path: str, body: dict | None = None) -> dict:
30
+ r = httpx.post(f"{API_BASE_URL}{path}", headers=_headers(), json=body or {}, timeout=30)
31
+ if r.status_code not in (200, 201):
32
+ try:
33
+ data = r.json()
34
+ msg = data.get("message") or data.get("error") or r.text
35
+ except Exception:
36
+ msg = r.text[:200]
37
+ raise Exception(msg)
38
+ return r.json()
39
+
40
+
41
+ def _delete(path: str) -> dict:
42
+ r = httpx.delete(f"{API_BASE_URL}{path}", headers=_headers(), timeout=30)
43
+ if r.status_code not in (200, 204):
44
+ try:
45
+ data = r.json()
46
+ msg = data.get("message") or data.get("error") or r.text
47
+ except Exception:
48
+ msg = r.text[:200]
49
+ raise Exception(msg)
50
+ return r.json() if r.text else {}
51
+
52
+
53
+ # --- Projects ---
54
+
55
+ def get_projects() -> list:
56
+ data = _get("/api/kinship/projects")
57
+ return data.get("projects", data if isinstance(data, list) else [])
58
+
59
+
60
+ def get_project_workspaces(project_id: str) -> list:
61
+ data = _get(f"/api/kinship/projects/{project_id}/workspaces")
62
+ return data.get("workspaces", data if isinstance(data, list) else [])
63
+
64
+
65
+ # --- Workflows ---
66
+
67
+ def get_workspace_workflows(workspace_id: str) -> list:
68
+ data = _get(f"/api/kinship/workspaces/{workspace_id}/workflows")
69
+ return data.get("workflows", data if isinstance(data, list) else [])
70
+
71
+
72
+ def get_workflow(workflow_id: str) -> dict:
73
+ return _get(f"/api/kinship/workflows/{workflow_id}")
74
+
75
+
76
+ def delete_workflow(workspace_id: str, workflow_id: str) -> dict:
77
+ return _delete(f"/api/kinship/workspaces/{workspace_id}/workflows/{workflow_id}")
78
+
79
+
80
+ # --- GPU ---
81
+
82
+ def get_gpu_status() -> dict:
83
+ return _get("/api/kinship/gpu")
84
+
85
+
86
+ def launch_gpu(instance_type: str = "g6e.xlarge") -> dict:
87
+ return _post("/api/kinship/gpu", {"instanceType": instance_type})
88
+
89
+
90
+ def start_gpu() -> dict:
91
+ return _post("/api/kinship/gpu/start")
92
+
93
+
94
+ def stop_gpu() -> dict:
95
+ return _post("/api/kinship/gpu/stop")
96
+
97
+
98
+ def terminate_gpu() -> dict:
99
+ return _delete("/api/kinship/gpu")
100
+
101
+
102
+ def get_gpu_health() -> dict:
103
+ return _get("/api/kinship/gpu/health")
104
+
105
+
106
+ def get_gpu_host_stats() -> dict:
107
+ return _get("/api/kinship/gpu/host-stats")
108
+
109
+
110
+ def get_gpu_metrics() -> dict:
111
+ return _get("/api/kinship/gpu/metrics")
112
+
113
+
114
+ def set_gpu_workspace(workspace_id: str, project_id: str | None = None) -> dict:
115
+ return _post("/api/kinship/gpu/set-workspace", {"workspaceId": workspace_id, "projectId": project_id})
116
+
117
+
118
+ # --- Models ---
119
+
120
+ def get_models_catalog() -> dict:
121
+ return _get("/api/kinship/models/catalog")
122
+
123
+
124
+ def get_models_installed() -> dict:
125
+ return _get("/api/kinship/models/installed")
126
+
127
+
128
+ def install_model(url: str, filename: str, directory: str) -> dict:
129
+ return _post("/api/kinship/models/install", {"url": url, "filename": filename, "directory": directory})
130
+
131
+
132
+ def get_model_progress() -> dict:
133
+ return _get("/api/kinship/models/progress")
134
+
135
+
136
+ # --- Assets ---
137
+
138
+ def get_workspace_assets(workspace_id: str) -> list:
139
+ data = _get(f"/api/kinship/workspaces/{workspace_id}/assets?include_images=true&image_size=thumbnail")
140
+ return data.get("assets", data if isinstance(data, list) else [])
141
+
142
+
143
+ # --- Templates ---
144
+
145
+ def get_templates() -> list:
146
+ # Upstream templates from CDN
147
+ try:
148
+ r = httpx.get(f"{CLOUDFRONT_URL}/templates/index.json", timeout=15)
149
+ if r.status_code == 200:
150
+ return r.json()
151
+ except Exception:
152
+ pass
153
+ return []
154
+
155
+
156
+ # --- Custom Nodes ---
157
+
158
+ def get_custom_nodes_catalog() -> list:
159
+ data = _get("/api/kinship/customnodes/catalog")
160
+ return data.get("nodes", data if isinstance(data, list) else [])
161
+
162
+
163
+ def get_installed_nodes() -> list:
164
+ data = _get("/api/kinship/customnodes")
165
+ return data.get("nodes", data if isinstance(data, list) else [])
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+ """Cognito authentication — username/password with token refresh."""
3
+
4
+ import json
5
+ import time
6
+ import base64
7
+ from pathlib import Path
8
+
9
+ import boto3
10
+ import jwt
11
+ from rich.console import Console
12
+
13
+ from .config import COGNITO_REGION, CLIENT_ID
14
+
15
+ console = Console()
16
+
17
+ TOKEN_FILE = Path.home() / ".config" / "twelveten-cli" / "tokens.json"
18
+
19
+
20
+ def _load_tokens() -> dict:
21
+ if TOKEN_FILE.exists():
22
+ try:
23
+ return json.loads(TOKEN_FILE.read_text())
24
+ except Exception:
25
+ return {}
26
+ return {}
27
+
28
+
29
+ def _save_tokens(tokens: dict):
30
+ TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
31
+ TOKEN_FILE.write_text(json.dumps(tokens, indent=2))
32
+
33
+
34
+ def get_tokens() -> dict:
35
+ return _load_tokens()
36
+
37
+
38
+ def get_id_token() -> "str | None":
39
+ tokens = _load_tokens()
40
+ id_token = tokens.get("id_token")
41
+ expires_at = tokens.get("expires_at", 0)
42
+ if id_token and time.time() < expires_at:
43
+ return id_token
44
+ # Try refresh
45
+ if tokens.get("refresh_token"):
46
+ refreshed = try_refresh(tokens["refresh_token"])
47
+ if refreshed:
48
+ return refreshed
49
+ return None
50
+
51
+
52
+ def get_email() -> str:
53
+ tokens = _load_tokens()
54
+ return tokens.get("email", "")
55
+
56
+
57
+ def is_authenticated() -> bool:
58
+ return get_id_token() is not None
59
+
60
+
61
+ def try_refresh(refresh_token: str) -> "str | None":
62
+ try:
63
+ client = boto3.client("cognito-idp", region_name=COGNITO_REGION)
64
+ result = client.initiate_auth(
65
+ AuthFlow="REFRESH_TOKEN_AUTH",
66
+ ClientId=CLIENT_ID,
67
+ AuthParameters={"REFRESH_TOKEN": refresh_token},
68
+ )
69
+ auth = result["AuthenticationResult"]
70
+ id_token = auth["IdToken"]
71
+ payload = jwt.decode(id_token, options={"verify_signature": False})
72
+ tokens = _load_tokens()
73
+ tokens["id_token"] = id_token
74
+ tokens["access_token"] = auth.get("AccessToken", "")
75
+ tokens["email"] = payload.get("email", payload.get("cognito:username", ""))
76
+ tokens["expires_at"] = time.time() + auth.get("ExpiresIn", 3600)
77
+ _save_tokens(tokens)
78
+ return id_token
79
+ except Exception:
80
+ return None
81
+
82
+
83
+ def login(email: str, password: str) -> str:
84
+ """Authenticate with Cognito and store tokens."""
85
+ client = boto3.client("cognito-idp", region_name=COGNITO_REGION)
86
+ result = client.initiate_auth(
87
+ AuthFlow="USER_PASSWORD_AUTH",
88
+ ClientId=CLIENT_ID,
89
+ AuthParameters={"USERNAME": email, "PASSWORD": password},
90
+ )
91
+ auth = result["AuthenticationResult"]
92
+ id_token = auth["IdToken"]
93
+ payload = jwt.decode(id_token, options={"verify_signature": False})
94
+ user_email = payload.get("email", payload.get("cognito:username", email))
95
+
96
+ _save_tokens({
97
+ "id_token": id_token,
98
+ "access_token": auth.get("AccessToken", ""),
99
+ "refresh_token": auth.get("RefreshToken", ""),
100
+ "email": user_email,
101
+ "expires_at": time.time() + auth.get("ExpiresIn", 3600),
102
+ })
103
+ return user_email
104
+
105
+
106
+ def logout():
107
+ """Clear stored tokens."""
108
+ if TOKEN_FILE.exists():
109
+ TOKEN_FILE.unlink()
@@ -0,0 +1,8 @@
1
+ """CLI configuration — Cognito + API settings."""
2
+
3
+ COGNITO_REGION = "us-east-2"
4
+ USER_POOL_ID = "us-east-2_k0coIihYf"
5
+ CLIENT_ID = "6uot5ne9h82hip5qllm22i15nc"
6
+
7
+ API_BASE_URL = "https://282gn3ghn9.execute-api.us-east-2.amazonaws.com/prod"
8
+ CLOUDFRONT_URL = "https://d35a8ja1c8626m.cloudfront.net"
@@ -0,0 +1,364 @@
1
+ from __future__ import annotations
2
+ """TwelveTen CLI — main entry point with click commands."""
3
+
4
+ import json
5
+ import sys
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ from . import __version__
11
+ from .auth import is_authenticated, login, logout, get_email
12
+ from .ui import show_splash, show_table, paginate_table, status_style, console as ui_console
13
+ from . import api
14
+
15
+ console = Console()
16
+
17
+
18
+ class Context:
19
+ """Shared CLI context."""
20
+ def __init__(self):
21
+ self.project_id: str = ""
22
+ self.project_name: str = ""
23
+ self.workspace_id: str = ""
24
+ self.workspace_name: str = ""
25
+ self.json_output: bool = False
26
+
27
+
28
+ pass_ctx = click.make_pass_decorator(Context, ensure=True)
29
+
30
+
31
+ @click.group(invoke_without_command=True)
32
+ @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
33
+ @click.option("--version", is_flag=True, help="Show version")
34
+ @click.pass_context
35
+ def cli(ctx, json_output, version):
36
+ """TwelveTen CLI — AI Workflow Platform"""
37
+ ctx.ensure_object(Context)
38
+ ctx.obj.json_output = json_output
39
+ if version:
40
+ click.echo(f"1210 v{__version__}")
41
+ return
42
+ if ctx.invoked_subcommand is None:
43
+ # No subcommand → interactive REPL
44
+ from .repl import start_repl
45
+ start_repl()
46
+
47
+
48
+ @cli.command("login")
49
+ def login_command():
50
+ """Authenticate with TwelveTen."""
51
+ email = click.prompt("Email")
52
+ password = click.prompt("Password", hide_input=True)
53
+ try:
54
+ user = login(email, password)
55
+ console.print(f" [green]✓ Logged in as {user}[/green]")
56
+ except Exception as e:
57
+ console.print(f" [red]✗ {e}[/red]")
58
+ sys.exit(1)
59
+
60
+
61
+ # Register as 'login' (avoid conflict with builtin)
62
+
63
+
64
+ @cli.command("login")
65
+ def logout_command():
66
+ """Clear stored credentials."""
67
+ logout()
68
+ console.print(" [green]✓ Logged out[/green]")
69
+
70
+
71
+
72
+
73
+ @cli.group(invoke_without_command=True)
74
+ @pass_ctx
75
+ def workflows(ctx):
76
+ """Manage workflows."""
77
+ if click.get_current_context().invoked_subcommand is None:
78
+ click.echo("Usage: 1210 workflows [list|info|run|delete]")
79
+
80
+
81
+ @workflows.command("list")
82
+ @click.option("--json", "json_output", is_flag=True)
83
+ @pass_ctx
84
+ def workflows_list(ctx, json_output):
85
+ """List workflows in the current workspace."""
86
+ _ensure_context(ctx)
87
+ wfs = api.get_workspace_workflows(ctx.workspace_id)
88
+ if json_output:
89
+ click.echo(json.dumps(wfs, indent=2))
90
+ return
91
+ rows = [[w.get("name", w.get("id", "—")), f"v{w.get('version', 1)}", w.get("folder", "—")] for w in wfs]
92
+ paginate_table("Workflows", ["Name", "Version", "Folder"], rows)
93
+
94
+
95
+ @workflows.command("info")
96
+ @click.argument("name")
97
+ @click.option("--json", "json_output", is_flag=True)
98
+ @pass_ctx
99
+ def workflows_info(ctx, name, json_output):
100
+ """Show workflow details."""
101
+ _ensure_context(ctx)
102
+ wfs = api.get_workspace_workflows(ctx.workspace_id)
103
+ match = next((w for w in wfs if w.get("id") == name or w.get("name") == name or name in (w.get("name") or "")), None)
104
+ if not match:
105
+ console.print(f" [red]✗ Workflow '{name}' not found[/red]")
106
+ return
107
+ wf = api.get_workflow(match["id"])
108
+ if json_output:
109
+ click.echo(json.dumps(wf, indent=2))
110
+ return
111
+ console.print(f"\n [dim]ID:[/dim] {match['id']}")
112
+ console.print(f" [dim]Name:[/dim] {wf.get('name', '—')}")
113
+ console.print(f" [dim]Version:[/dim] v{wf.get('version', 1)}")
114
+ console.print(f" [dim]Nodes:[/dim] {len(wf.get('workflow', {}).get('nodes', []))}")
115
+ console.print()
116
+
117
+
118
+ @cli.group(invoke_without_command=True)
119
+ @pass_ctx
120
+ def gpu(ctx):
121
+ """GPU lifecycle management."""
122
+ if click.get_current_context().invoked_subcommand is None:
123
+ click.echo("Usage: 1210 gpu [status|launch|start|stop|terminate|stats]")
124
+
125
+
126
+ @gpu.command("status")
127
+ @click.option("--json", "json_output", is_flag=True)
128
+ @pass_ctx
129
+ def gpu_status(ctx, json_output):
130
+ """Show GPU instance status."""
131
+ try:
132
+ data = api.get_gpu_status()
133
+ except Exception as e:
134
+ if "No GPU" in str(e):
135
+ console.print(" [dim]No GPU instance. Use: 1210 gpu launch[/dim]")
136
+ return
137
+ raise
138
+ status = data.get("status", "unknown")
139
+ # Check health for ready/initializing
140
+ health_status = ""
141
+ if status == "running":
142
+ try:
143
+ health = api.get_gpu_health()
144
+ health_status = "ready" if health.get("healthy") else "initializing"
145
+ except Exception:
146
+ health_status = "initializing"
147
+ display = health_status or status
148
+ if json_output:
149
+ data["health"] = health_status
150
+ click.echo(json.dumps(data, indent=2))
151
+ return
152
+ console.print(f"\n [dim]Status:[/dim] {status_style(display)}")
153
+ console.print(f" [dim]Instance:[/dim] {data.get('instanceType', '—')}")
154
+ if data.get("createdAt"):
155
+ console.print(f" [dim]Launched:[/dim] {data['createdAt']}")
156
+ console.print(f" [dim]ComfyUI:[/dim] {'[green]Ready[/green]' if health_status == 'ready' else '[yellow]Initializing[/yellow]'}")
157
+ console.print()
158
+
159
+
160
+ @gpu.command("launch")
161
+ @click.argument("instance_type", default="g6e.xlarge")
162
+ @pass_ctx
163
+ def gpu_launch(ctx, instance_type):
164
+ """Launch a GPU instance."""
165
+ api.launch_gpu(instance_type)
166
+ console.print(f" [green]✓ GPU {instance_type} launch initiated[/green]")
167
+ console.print(" [dim]Use 'gpu status' to check progress.[/dim]")
168
+
169
+
170
+ @gpu.command("start")
171
+ def gpu_start():
172
+ """Start a stopped GPU."""
173
+ api.start_gpu()
174
+ console.print(" [green]✓ GPU start initiated[/green]")
175
+
176
+
177
+ @gpu.command("stop")
178
+ def gpu_stop():
179
+ """Stop the running GPU."""
180
+ api.stop_gpu()
181
+ console.print(" [green]✓ GPU stopped[/green]")
182
+
183
+
184
+ @gpu.command("terminate")
185
+ def gpu_terminate():
186
+ """Terminate the GPU instance."""
187
+ api.terminate_gpu()
188
+ console.print(" [green]✓ GPU terminated[/green]")
189
+
190
+
191
+ @gpu.command("stats")
192
+ @click.option("--json", "json_output", is_flag=True)
193
+ @pass_ctx
194
+ def gpu_stats(ctx, json_output):
195
+ """Show GPU performance stats."""
196
+ # Check readiness
197
+ try:
198
+ data = api.get_gpu_status()
199
+ if data.get("status") != "running":
200
+ console.print(f" [yellow]GPU is {data.get('status', 'not running')}[/yellow]")
201
+ return
202
+ health = api.get_gpu_health()
203
+ if not health.get("healthy"):
204
+ console.print(" [yellow]GPU still initializing. Stats unavailable.[/yellow]")
205
+ return
206
+ except Exception as e:
207
+ console.print(f" [yellow]{e}[/yellow]")
208
+ return
209
+
210
+ stats = api.get_gpu_host_stats()
211
+ if json_output:
212
+ click.echo(json.dumps(stats, indent=2))
213
+ return
214
+ gpu_data = stats.get("stats", {}).get("gpu")
215
+ sys_data = stats.get("stats", {}).get("system")
216
+ cost = stats.get("cost")
217
+ console.print()
218
+ if gpu_data:
219
+ console.print(" [bold]GPU[/bold]")
220
+ console.print(f" [dim]Util:[/dim] [green]{gpu_data.get('gpu_util', 0)}%[/green]")
221
+ vram_used = gpu_data.get("vram_used_mb", 0) / 1024
222
+ vram_total = gpu_data.get("vram_total_mb", 1) / 1024
223
+ console.print(f" [dim]VRAM:[/dim] [blue]{vram_used:.1f} / {vram_total:.1f} GB ({gpu_data.get('mem_util', 0)}%)[/blue]")
224
+ console.print(f" [dim]Temp:[/dim] {gpu_data.get('temp_c', 0)}°C")
225
+ console.print(f" [dim]Name:[/dim] {gpu_data.get('name', '—')}")
226
+ if sys_data:
227
+ console.print(" [bold]System[/bold]")
228
+ console.print(f" [dim]CPU:[/dim] {sys_data.get('load_1m', 0)} (1m avg)")
229
+ mem = sys_data.get("memory", {})
230
+ used = mem.get("used_mb") or mem.get("ram_used_mb") or 0
231
+ total = mem.get("total_mb") or mem.get("ram_total_mb") or 0
232
+ if total:
233
+ console.print(f" [dim]RAM:[/dim] {used/1024:.1f} / {total/1024:.1f} GB ({round(used/total*100)}%)")
234
+ if cost:
235
+ console.print(" [bold]Cost[/bold]")
236
+ console.print(f" [dim]Rate:[/dim] ${cost.get('hourly_rate', 0):.2f}/hr")
237
+ console.print(f" [dim]Total:[/dim] ${cost.get('estimated_cost', 0):.2f}")
238
+ console.print()
239
+
240
+
241
+ @cli.group(invoke_without_command=True)
242
+ @pass_ctx
243
+ def models(ctx):
244
+ """Model management."""
245
+ if click.get_current_context().invoked_subcommand is None:
246
+ click.echo("Usage: 1210 models [list|installed|download|progress]")
247
+
248
+
249
+ @models.command("list")
250
+ @click.option("--json", "json_output", is_flag=True)
251
+ @pass_ctx
252
+ def models_list(ctx, json_output):
253
+ """Browse the model catalog."""
254
+ data = api.get_models_catalog()
255
+ rows_data = data.get("rows", [])
256
+ if json_output:
257
+ click.echo(json.dumps(rows_data, indent=2))
258
+ return
259
+ rows = [[r.get("title", "—"), r.get("classified_function", "—"), str(r.get("variant_count", 0))] for r in rows_data]
260
+ paginate_table("Model Catalog", ["Model", "Function", "Variants"], rows)
261
+
262
+
263
+ @models.command("installed")
264
+ @click.option("--json", "json_output", is_flag=True)
265
+ @pass_ctx
266
+ def models_installed(ctx, json_output):
267
+ """List installed models."""
268
+ data = api.get_models_installed()
269
+ models_list = data.get("models", data if isinstance(data, list) else [])
270
+ if json_output:
271
+ click.echo(json.dumps(models_list, indent=2))
272
+ return
273
+ if not models_list:
274
+ console.print(" [yellow]No models installed.[/yellow]")
275
+ return
276
+ rows = [[m.get("filename", "—"), m.get("directory", "—")] for m in models_list[:30]]
277
+ show_table("Installed Models", ["Filename", "Directory"], rows, len(models_list))
278
+
279
+
280
+ @cli.group(invoke_without_command=True)
281
+ @pass_ctx
282
+ def assets(ctx):
283
+ """Workspace assets."""
284
+ if click.get_current_context().invoked_subcommand is None:
285
+ click.echo("Usage: 1210 assets [list|open]")
286
+
287
+
288
+ @assets.command("list")
289
+ @click.option("--json", "json_output", is_flag=True)
290
+ @pass_ctx
291
+ def assets_list(ctx, json_output):
292
+ """List workspace assets."""
293
+ _ensure_context(ctx)
294
+ items = api.get_workspace_assets(ctx.workspace_id)
295
+ if json_output:
296
+ click.echo(json.dumps(items, indent=2))
297
+ return
298
+ if not items:
299
+ console.print(" [yellow]No assets found.[/yellow]")
300
+ return
301
+ rows = [[a.get("filename") or a.get("name", "—"), a.get("folder", "—")] for a in items]
302
+ show_table("Assets", ["Filename", "Folder"], rows, len(items))
303
+
304
+
305
+ @assets.command("open")
306
+ @click.argument("name")
307
+ @pass_ctx
308
+ def assets_open(ctx, name):
309
+ """Open an asset in browser."""
310
+ _ensure_context(ctx)
311
+ items = api.get_workspace_assets(ctx.workspace_id)
312
+ match = next((a for a in items if name in (a.get("filename") or a.get("name", ""))), None)
313
+ if not match:
314
+ console.print(f" [red]✗ Asset '{name}' not found[/red]")
315
+ return
316
+ url = match.get("s3_url") or match.get("full_url") or match.get("preview_url")
317
+ if not url:
318
+ console.print(" [red]✗ No preview URL available[/red]")
319
+ return
320
+ import webbrowser
321
+ webbrowser.open(url)
322
+ console.print(f" [green]✓ Opened: {match.get('filename') or match.get('name')}[/green]")
323
+
324
+
325
+ @cli.command("templates")
326
+ @click.option("--json", "json_output", is_flag=True)
327
+ @pass_ctx
328
+ def templates_list(ctx, json_output):
329
+ """Browse template library."""
330
+ categories = api.get_templates()
331
+ if json_output:
332
+ click.echo(json.dumps(categories, indent=2))
333
+ return
334
+ if not categories:
335
+ console.print(" [yellow]No templates found.[/yellow]")
336
+ return
337
+ all_templates = []
338
+ for cat in categories:
339
+ for t in cat.get("templates", []):
340
+ all_templates.append([cat.get("title", "—"), t.get("title") or t.get("name", "—"), (t.get("description", "")[:40])])
341
+ paginate_table("Templates", ["Category", "Name", "Description"], all_templates)
342
+
343
+
344
+ def _ensure_context(ctx: Context):
345
+ """Load saved context or prompt user."""
346
+ if ctx.workspace_id:
347
+ return
348
+ # Try loading from saved config
349
+ from pathlib import Path
350
+ config_file = Path.home() / ".config" / "twelveten-cli" / "context.json"
351
+ if config_file.exists():
352
+ data = json.loads(config_file.read_text())
353
+ ctx.project_id = data.get("project_id", "")
354
+ ctx.project_name = data.get("project_name", "")
355
+ ctx.workspace_id = data.get("workspace_id", "")
356
+ ctx.workspace_name = data.get("workspace_name", "")
357
+ if ctx.workspace_id:
358
+ return
359
+ console.print(" [yellow]No workspace set. Run interactive mode: 1210[/yellow]")
360
+ sys.exit(1)
361
+
362
+
363
+ if __name__ == "__main__":
364
+ cli()
@@ -0,0 +1,387 @@
1
+ from __future__ import annotations
2
+ """Interactive REPL with tab completion, history, and pagination."""
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from prompt_toolkit import PromptSession
9
+ from prompt_toolkit.completion import WordCompleter, NestedCompleter
10
+ from prompt_toolkit.history import FileHistory
11
+ from rich.console import Console
12
+
13
+ from . import api
14
+ from .auth import is_authenticated, login, get_email, logout
15
+ from .ui import show_splash, show_table, paginate_table, status_style
16
+
17
+ console = Console()
18
+
19
+ HISTORY_FILE = Path.home() / ".config" / "twelveten-cli" / "history"
20
+ CONTEXT_FILE = Path.home() / ".config" / "twelveten-cli" / "context.json"
21
+
22
+
23
+ def _save_context(ctx: dict):
24
+ CONTEXT_FILE.parent.mkdir(parents=True, exist_ok=True)
25
+ CONTEXT_FILE.write_text(json.dumps(ctx, indent=2))
26
+
27
+
28
+ def _select(prompt: str, choices: list, name_key="name", value_key="id") -> str:
29
+ """Interactive arrow-key selection (like Node's inquirer)."""
30
+ from InquirerPy import inquirer
31
+
32
+ options = []
33
+ for c in choices:
34
+ label = c.get(name_key, c.get(value_key, "?"))
35
+ desc = c.get("description", "")
36
+ display = f"{label} — {desc}" if desc else label
37
+ options.append({"name": display, "value": c[value_key]})
38
+
39
+ result = inquirer.select(
40
+ message=prompt,
41
+ choices=options,
42
+ pointer="❯",
43
+ ).execute()
44
+ return result
45
+
46
+
47
+ def start_repl():
48
+ """Main interactive REPL entry point."""
49
+ show_splash()
50
+
51
+ # Auth
52
+ if not is_authenticated():
53
+ console.print(" [yellow]⚠ Not authenticated[/yellow]\n")
54
+ email = console.input(" Email: ")
55
+ from getpass import getpass
56
+ password = getpass(" Password: ")
57
+ try:
58
+ user = login(email, password)
59
+ console.print(f"\n [green]✓ Logged in as {user}[/green]\n")
60
+ except Exception as e:
61
+ console.print(f"\n [red]✗ {e}[/red]")
62
+ sys.exit(1)
63
+ else:
64
+ console.print(f" [green]✓ Authenticated as [bold]{get_email()}[/bold][/green]\n")
65
+
66
+ # Select project
67
+ console.print(" [dim]Fetching projects...[/dim]")
68
+ projects = api.get_projects()
69
+ if not projects:
70
+ console.print(" [yellow]No projects found.[/yellow]")
71
+ sys.exit(0)
72
+ project_id = _select("Select a project", projects)
73
+ project_name = next((p["name"] for p in projects if p["id"] == project_id), project_id)
74
+
75
+ # Select workspace
76
+ console.print("\n [dim]Fetching workspaces...[/dim]")
77
+ workspaces = api.get_project_workspaces(project_id)
78
+ if not workspaces:
79
+ console.print(" [yellow]No workspaces found.[/yellow]")
80
+ sys.exit(0)
81
+ workspace_id = _select("Select a workspace", workspaces)
82
+ workspace_name = next((w.get("name", w["id"]) for w in workspaces if w["id"] == workspace_id), workspace_id)
83
+
84
+ # Save context
85
+ ctx = {"project_id": project_id, "project_name": project_name,
86
+ "workspace_id": workspace_id, "workspace_name": workspace_name}
87
+ _save_context(ctx)
88
+
89
+ console.print(f"\n [dim]Connected to your [white]{project_name}[/white] project and the [white]{workspace_name}[/white] workspace[/dim]")
90
+ console.print(" [dim]Type 'help' for commands, 'exit' to quit.[/dim]\n")
91
+
92
+ # REPL loop
93
+ completer = NestedCompleter.from_nested_dict({
94
+ "workflows": {"list": None, "info": None, "run": None, "delete": None},
95
+ "gpu": {"status": None, "launch": None, "start": None, "stop": None, "terminate": None, "stats": None},
96
+ "models": {"list": None, "installed": None, "progress": None},
97
+ "assets": {"list": None, "open": None},
98
+ "nodes": {"list": None, "installed": None, "search": None, "show": None},
99
+ "templates": {"list": None},
100
+ "status": None,
101
+ "logout": None,
102
+ "help": None,
103
+ "clear": None,
104
+ "exit": None,
105
+ })
106
+
107
+ HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
108
+ session = PromptSession(
109
+ history=FileHistory(str(HISTORY_FILE)),
110
+ completer=completer,
111
+ )
112
+
113
+ prompt_text = f"1210 {project_name}/{workspace_name} > "
114
+
115
+ while True:
116
+ try:
117
+ text = session.prompt(prompt_text)
118
+ except (EOFError, KeyboardInterrupt):
119
+ console.print("\n [dim]Goodbye.[/dim]\n")
120
+ break
121
+
122
+ parts = text.strip().split()
123
+ if not parts:
124
+ continue
125
+
126
+ try:
127
+ handle_command(parts, ctx)
128
+ except Exception as e:
129
+ console.print(f" [red]✗ {e}[/red]\n")
130
+
131
+
132
+ def handle_command(parts: list, ctx: dict):
133
+ group = parts[0]
134
+ action = parts[1] if len(parts) > 1 else None
135
+ args = parts[2:] if len(parts) > 2 else []
136
+ ws_id = ctx["workspace_id"]
137
+
138
+ if group in ("exit", "quit"):
139
+ console.print("\n [dim]Goodbye.[/dim]\n")
140
+ sys.exit(0)
141
+ elif group == "clear":
142
+ console.clear()
143
+ elif group in ("help", "?"):
144
+ _show_help()
145
+ elif group in ("status", "whoami"):
146
+ console.print(f"\n [dim]User:[/dim] {get_email()}")
147
+ console.print(f" [dim]Project:[/dim] {ctx['project_name']}")
148
+ console.print(f" [dim]Workspace:[/dim] {ctx['workspace_name']}\n")
149
+ elif group == "logout":
150
+ logout()
151
+ console.print("\n [green]✓ Logged out. Restart CLI to sign in as another user.[/green]\n")
152
+ sys.exit(0)
153
+ elif group in ("workflows", "wf"):
154
+ _handle_workflows(action, args, ws_id)
155
+ elif group == "gpu":
156
+ _handle_gpu(action, args, ctx)
157
+ elif group == "models":
158
+ _handle_models(action, args)
159
+ elif group == "assets":
160
+ _handle_assets(action, args, ws_id)
161
+ elif group == "templates":
162
+ _handle_templates()
163
+ elif group == "nodes":
164
+ _handle_nodes(action, args)
165
+ else:
166
+ console.print(f" [yellow]Unknown: '{group}'. Type 'help' for commands.[/yellow]\n")
167
+
168
+
169
+ def _show_help():
170
+ console.print("\n [bold]Commands:[/bold]\n")
171
+ cmds = [
172
+ ("workflows", "list, info <name>, run <name>, delete <name>", "Manage workflows"),
173
+ ("gpu", "status, launch, start, stop, terminate, stats", "GPU lifecycle"),
174
+ ("models", "list, installed, progress", "Model management"),
175
+ ("assets", "list, open <name>", "Workspace assets"),
176
+ ("templates", "list", "Browse templates"),
177
+ ]
178
+ for cmd, sub, desc in cmds:
179
+ console.print(f" [cyan]{cmd:<14}[/cyan][dim]{sub:<50}[/dim]{desc}")
180
+ console.print()
181
+ utils = [("status", "Show current context"), ("logout", "Sign out and switch user"), ("clear", "Clear screen"), ("exit", "Exit CLI")]
182
+ for cmd, desc in utils:
183
+ console.print(f" [cyan]{cmd:<14}[/cyan]{desc}")
184
+ console.print()
185
+ console.print(" [dim]Tip: Use direct mode for JSON output: 1210 workflows list --json[/dim]\n")
186
+
187
+
188
+ def _handle_workflows(action, args, ws_id):
189
+ if action in ("list", "ls", None):
190
+ wfs = api.get_workspace_workflows(ws_id)
191
+ rows = [[w.get("name", w.get("id")), f"v{w.get('version', 1)}", w.get("folder", "—")] for w in wfs]
192
+ paginate_table("Workflows", ["Name", "Version", "Folder"], rows)
193
+ elif action == "info":
194
+ name = " ".join(args) if args else ""
195
+ if not name:
196
+ console.print(" [yellow]Usage: workflows info <name>[/yellow]\n"); return
197
+ wfs = api.get_workspace_workflows(ws_id)
198
+ match = next((w for w in wfs if w.get("name") == name or name in (w.get("name") or "")), None)
199
+ if not match:
200
+ console.print(f" [red]✗ '{name}' not found[/red]\n"); return
201
+ wf = api.get_workflow(match["id"])
202
+ console.print(f"\n [dim]ID:[/dim] {match['id']}")
203
+ console.print(f" [dim]Name:[/dim] {wf.get('name')}")
204
+ console.print(f" [dim]Version:[/dim] v{wf.get('version', 1)}")
205
+ console.print(f" [dim]Nodes:[/dim] {len(wf.get('workflow', {}).get('nodes', []))}\n")
206
+ else:
207
+ console.print(" [yellow]Usage: workflows [list|info <name>][/yellow]\n")
208
+
209
+
210
+ def _handle_gpu(action, args, ctx):
211
+ if action in ("status", None):
212
+ try:
213
+ data = api.get_gpu_status()
214
+ except Exception as e:
215
+ if "No GPU" in str(e):
216
+ console.print(" [dim]No GPU instance. Use: gpu launch[/dim]\n"); return
217
+ raise
218
+ status = data.get("status", "unknown")
219
+ health = ""
220
+ if status == "running":
221
+ try:
222
+ h = api.get_gpu_health()
223
+ health = "ready" if h.get("healthy") else "initializing"
224
+ except Exception:
225
+ health = "initializing"
226
+ display = health or status
227
+ console.print(f"\n [dim]Status:[/dim] {status_style(display)}")
228
+ console.print(f" [dim]Instance:[/dim] {data.get('instanceType', '—')}")
229
+ console.print(f" [dim]ComfyUI:[/dim] {'[green]Ready[/green]' if health == 'ready' else '[yellow]Initializing[/yellow]'}\n")
230
+ elif action == "launch":
231
+ itype = args[0] if args else "g6e.xlarge"
232
+ api.launch_gpu(itype)
233
+ console.print(f" [green]✓ GPU {itype} launch initiated[/green]\n")
234
+ elif action == "start":
235
+ api.start_gpu()
236
+ console.print(" [green]✓ GPU start initiated[/green]\n")
237
+ elif action == "stop":
238
+ api.stop_gpu()
239
+ console.print(" [green]✓ GPU stopped[/green]\n")
240
+ elif action in ("terminate", "destroy"):
241
+ api.terminate_gpu()
242
+ console.print(" [green]✓ GPU terminated[/green]\n")
243
+ elif action == "stats":
244
+ data = api.get_gpu_status()
245
+ if data.get("status") != "running":
246
+ console.print(f" [yellow]GPU is {data.get('status', 'not running')}[/yellow]\n"); return
247
+ try:
248
+ h = api.get_gpu_health()
249
+ if not h.get("healthy"):
250
+ console.print(" [yellow]GPU initializing. Stats unavailable.[/yellow]\n"); return
251
+ except Exception:
252
+ console.print(" [yellow]GPU not ready.[/yellow]\n"); return
253
+ stats = api.get_gpu_host_stats()
254
+ gpu = stats.get("stats", {}).get("gpu")
255
+ sys_data = stats.get("stats", {}).get("system")
256
+ cost = stats.get("cost")
257
+ console.print()
258
+ if gpu:
259
+ console.print(" [bold]GPU[/bold]")
260
+ console.print(f" [dim]Util:[/dim] [green]{gpu.get('gpu_util', 0)}%[/green]")
261
+ console.print(f" [dim]VRAM:[/dim] [blue]{gpu.get('vram_used_mb',0)/1024:.1f} / {gpu.get('vram_total_mb',1)/1024:.1f} GB[/blue]")
262
+ console.print(f" [dim]Temp:[/dim] {gpu.get('temp_c', 0)}°C")
263
+ if sys_data:
264
+ console.print(" [bold]System[/bold]")
265
+ console.print(f" [dim]CPU:[/dim] {sys_data.get('load_1m', 0)} (1m avg)")
266
+ mem = sys_data.get("memory", {})
267
+ used = mem.get("used_mb") or mem.get("ram_used_mb") or 0
268
+ total = mem.get("total_mb") or mem.get("ram_total_mb") or 0
269
+ if total:
270
+ console.print(f" [dim]RAM:[/dim] {used/1024:.1f} / {total/1024:.1f} GB")
271
+ if cost:
272
+ console.print(" [bold]Cost[/bold]")
273
+ console.print(f" [dim]Rate:[/dim] ${cost.get('hourly_rate',0):.2f}/hr")
274
+ console.print(f" [dim]Total:[/dim] ${cost.get('estimated_cost',0):.2f}")
275
+ console.print()
276
+ else:
277
+ console.print(" [yellow]Usage: gpu [status|launch|start|stop|terminate|stats][/yellow]\n")
278
+
279
+
280
+ def _handle_models(action, args):
281
+ if action in ("list", None):
282
+ data = api.get_models_catalog()
283
+ rows = [[r.get("title", "—"), r.get("classified_function", "—"), str(r.get("variant_count", 0))] for r in data.get("rows", [])]
284
+ paginate_table("Model Catalog", ["Model", "Function", "Variants"], rows)
285
+ elif action == "installed":
286
+ data = api.get_models_installed()
287
+ models = data.get("models", data if isinstance(data, list) else [])
288
+ if not models:
289
+ console.print(" [yellow]No models installed.[/yellow]\n"); return
290
+ rows = [[m.get("filename", "—"), m.get("directory", "—")] for m in models]
291
+ show_table("Installed Models", ["Filename", "Directory"], rows, len(models))
292
+ else:
293
+ console.print(" [yellow]Usage: models [list|installed|progress][/yellow]\n")
294
+
295
+
296
+ def _handle_assets(action, args, ws_id):
297
+ if action in ("list", "ls"):
298
+ items = api.get_workspace_assets(ws_id)
299
+ if not items:
300
+ console.print(" [yellow]No assets found.[/yellow]\n"); return
301
+ rows = [[a.get("filename") or a.get("name", "—"), a.get("folder", "—")] for a in items]
302
+ show_table("Assets", ["Filename", "Folder"], rows, len(items))
303
+ elif action == "open":
304
+ name = " ".join(args) if args else ""
305
+ items = api.get_workspace_assets(ws_id)
306
+ if not items:
307
+ console.print(" [yellow]No assets found.[/yellow]\n"); return
308
+ if not name:
309
+ # Interactive selection
310
+ from InquirerPy import inquirer
311
+ options = [{"name": a.get("filename") or a.get("name", "—"), "value": a.get("filename") or a.get("name")} for a in items]
312
+ try:
313
+ name = inquirer.select(message="Select asset to open", choices=options, pointer="❯").execute()
314
+ except (KeyboardInterrupt, EOFError):
315
+ console.print(" [dim]Cancelled.[/dim]\n"); return
316
+ match = next((a for a in items if name in (a.get("filename") or a.get("name", ""))), None)
317
+ if not match:
318
+ console.print(f" [red]✗ '{name}' not found[/red]\n"); return
319
+ url = match.get("s3_url") or match.get("full_url") or match.get("preview_url")
320
+ if not url:
321
+ console.print(" [red]✗ No URL available[/red]\n"); return
322
+ import webbrowser
323
+ webbrowser.open(url)
324
+ console.print(f" [green]✓ Opened: {match.get('filename') or match.get('name')}[/green]\n")
325
+ else:
326
+ console.print(" [yellow]Usage: assets [list|open <name>][/yellow]\n")
327
+
328
+
329
+ def _handle_templates():
330
+ categories = api.get_templates()
331
+ if not categories:
332
+ console.print(" [yellow]No templates found.[/yellow]\n"); return
333
+ all_t = []
334
+ for cat in categories:
335
+ for t in cat.get("templates", []):
336
+ all_t.append([cat.get("title", "—"), t.get("title") or t.get("name", "—"), (t.get("description", "")[:40])])
337
+ paginate_table("Templates", ["Category", "Name", "Description"], all_t)
338
+
339
+
340
+ def _handle_nodes(action, args):
341
+ if action in ("list", "catalog", None):
342
+ nodes = api.get_custom_nodes_catalog()
343
+ if not nodes:
344
+ console.print(" [yellow]No custom nodes in catalog.[/yellow]\n"); return
345
+ rows = [
346
+ [
347
+ n.get("title") or n.get("name", "—"),
348
+ n.get("author", "—"),
349
+ f"⭐ {n.get('stars', 0)}" if n.get("stars") else "—",
350
+ "[red]Blocked[/red]" if n.get("blacklisted_platform") else "[green]OK[/green]"
351
+ ]
352
+ for n in nodes
353
+ ]
354
+ paginate_table("Custom Nodes Catalog", ["Name", "Author", "Stars", "Status"], rows)
355
+ elif action == "installed":
356
+ nodes = api.get_installed_nodes()
357
+ if not nodes:
358
+ console.print(" [yellow]No custom nodes installed.[/yellow]\n"); return
359
+ rows = [[n.get("title") or n.get("name", "—"), n.get("author", "—"), n.get("version", "—")] for n in nodes]
360
+ show_table("Installed Nodes", ["Name", "Author", "Version"], rows, len(nodes))
361
+ elif action == "search":
362
+ query = " ".join(args).lower() if args else ""
363
+ if not query:
364
+ console.print(" [yellow]Usage: nodes search <query>[/yellow]\n"); return
365
+ nodes = api.get_custom_nodes_catalog()
366
+ matches = [n for n in nodes if query in (n.get("title", "") + n.get("name", "") + n.get("description", "")).lower()]
367
+ if not matches:
368
+ console.print(f" [yellow]No nodes matching '{query}'[/yellow]\n"); return
369
+ rows = [[n.get("title") or n.get("name", "—"), n.get("author", "—"), (n.get("description", "")[:45])] for n in matches]
370
+ show_table(f"Nodes matching '{query}'", ["Name", "Author", "Description"], rows, len(matches))
371
+ elif action == "show":
372
+ query = " ".join(args).lower() if args else ""
373
+ if not query:
374
+ console.print(" [yellow]Usage: nodes show <name>[/yellow]\n"); return
375
+ nodes = api.get_custom_nodes_catalog()
376
+ match = next((n for n in nodes if query in (n.get("title", "") + n.get("name", "")).lower()), None)
377
+ if not match:
378
+ console.print(f" [red]✗ Node '{query}' not found[/red]\n"); return
379
+ console.print(f"\n [bold]{match.get('title') or match.get('name')}[/bold]")
380
+ console.print(f" [dim]Author:[/dim] {match.get('author', '—')}")
381
+ console.print(f" [dim]Stars:[/dim] {match.get('stars', '—')}")
382
+ console.print(f" [dim]Description:[/dim] {match.get('description', '—')}")
383
+ if match.get("reference"):
384
+ console.print(f" [dim]URL:[/dim] [blue]{match['reference']}[/blue]")
385
+ console.print()
386
+ else:
387
+ console.print(" [yellow]Usage: nodes [list|installed|search <query>|show <name>][/yellow]\n")
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+ """UI helpers — splash screen, tables, formatting."""
3
+
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+ from rich.panel import Panel
7
+ from rich.text import Text
8
+ from rich import box
9
+
10
+ console = Console()
11
+
12
+ LOGO = r"""
13
+ ██╗ ██████╗ ██╗ ██████╗ ██████╗ ██╗ ██╗
14
+ ██║ ╚════██ ██║ ██╔═══██╗ ║██╔═══╝ ██║ ██║
15
+ ██║ █████╝ ██║ ██║ ██║ ║██║ ██║ ██║
16
+ ██║ ██╔═══╝ ██║ ██║ ██║ ║██║ ██║ ██║
17
+ ██║ ███████╗ ██║ ╚██████╔╝ ╚██████╗ ███████╗ ██║
18
+ ╚═╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝
19
+ """
20
+
21
+
22
+ def show_splash():
23
+ console.clear()
24
+ # Gradient effect on logo
25
+ from rich.text import Text
26
+ lines = LOGO.strip().split("\n")
27
+ colors = ["#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#d946ef", "#ec4899"]
28
+ for i, line in enumerate(lines):
29
+ color = colors[i % len(colors)]
30
+ console.print(f"[{color}]{line}[/{color}]")
31
+ console.print()
32
+ console.print(Panel(
33
+ "[bold white]TwelveTen CLI[/bold white] [dim]v0.1.0[/dim]\n\n"
34
+ "[white]Enterprise AI workflow platform.[/white]\n"
35
+ "[white]Managed GPU infrastructure. ComfyUI at scale.[/white]\n\n"
36
+ "[dim]docs:[/dim] [blue]https://twelveten.io[/blue] • "
37
+ "[dim]support:[/dim] [blue]hello@twelveten.ai[/blue]",
38
+ border_style="blue",
39
+ padding=(1, 2),
40
+ ))
41
+ console.print()
42
+
43
+
44
+ def show_table(title: str, columns: list, rows: list[list], count: "int | None" = None):
45
+ label = f" {title}" + (f" ({count})" if count else "")
46
+ console.print(f"\n[bold]{label}[/bold]\n")
47
+ table = Table(box=box.SIMPLE_HEAVY, show_edge=False, pad_edge=False, padding=(0, 2))
48
+ for col in columns:
49
+ table.add_column(col, style="cyan" if col == columns[0] else "")
50
+ for row in rows:
51
+ table.add_row(*[str(c) for c in row])
52
+ console.print(table)
53
+ console.print()
54
+
55
+
56
+ def paginate_table(title: str, columns: list, rows: list[list], page_size: int = 15):
57
+ """Display a table with pagination and consistent column widths."""
58
+ total = len(rows)
59
+ if total <= page_size:
60
+ show_table(title, columns, rows, total)
61
+ return
62
+
63
+ # Pre-calculate max column widths from ALL data for consistent layout
64
+ import re
65
+ col_widths = []
66
+ for i, col in enumerate(columns):
67
+ max_w = len(col)
68
+ for row in rows:
69
+ cell = str(row[i]) if i < len(row) else ""
70
+ clean = re.sub(r'\[/?[a-z]+\]', '', cell) # strip rich markup
71
+ max_w = max(max_w, len(clean))
72
+ col_widths.append(min(max_w, 55))
73
+
74
+ pages = (total + page_size - 1) // page_size
75
+ for page in range(pages):
76
+ start = page * page_size
77
+ chunk = rows[start:start + page_size]
78
+
79
+ label = f" {title} ({total})"
80
+ console.print(f"\n[bold]{label}[/bold]\n")
81
+ table = Table(box=box.SIMPLE_HEAVY, show_edge=False, pad_edge=False, padding=(0, 2))
82
+ for i, col in enumerate(columns):
83
+ style = "cyan" if i == 0 else ""
84
+ table.add_column(col, style=style, width=col_widths[i], no_wrap=True)
85
+ for row in chunk:
86
+ table.add_row(*[str(c) for c in row])
87
+ console.print(table)
88
+ console.print()
89
+
90
+ if page < pages - 1:
91
+ remaining = total - start - page_size
92
+ try:
93
+ answer = console.input(f" — Page {page + 1}/{pages} ({remaining} more) — enter=next, q=quit: ")
94
+ if answer.strip().lower() == "q":
95
+ break
96
+ except (EOFError, KeyboardInterrupt):
97
+ break
98
+
99
+
100
+ def status_style(status: str) -> str:
101
+ colors = {
102
+ "ready": "green", "running": "green",
103
+ "initializing": "yellow", "starting": "blue",
104
+ "stopped": "yellow", "stopping": "yellow",
105
+ "terminating": "red", "none": "dim",
106
+ }
107
+ color = colors.get(status, "white")
108
+ return f"[{color}]{status}[/{color}]"
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: twelveten-cli
3
+ Version: 0.1.0
4
+ Summary: TwelveTen CLI — AI workflow platform from your terminal
5
+ Author-email: TwelveTen <hello@twelveten.ai>
6
+ License: MIT
7
+ Project-URL: Homepage, https://twelveten.io
8
+ Project-URL: Repository, https://github.com/TwelveTenTippers/kinship
9
+ Keywords: cli,ai,comfyui,gpu,workflow
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Multimedia :: Graphics
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: click>=8.1
22
+ Requires-Dist: rich>=13.0
23
+ Requires-Dist: prompt-toolkit>=3.0
24
+ Requires-Dist: boto3>=1.34
25
+ Requires-Dist: httpx>=0.27
26
+ Requires-Dist: pyjwt>=2.8
27
+ Requires-Dist: InquirerPy>=0.3
@@ -0,0 +1,14 @@
1
+ pyproject.toml
2
+ twelveten_cli/__init__.py
3
+ twelveten_cli/api.py
4
+ twelveten_cli/auth.py
5
+ twelveten_cli/config.py
6
+ twelveten_cli/main.py
7
+ twelveten_cli/repl.py
8
+ twelveten_cli/ui.py
9
+ twelveten_cli.egg-info/PKG-INFO
10
+ twelveten_cli.egg-info/SOURCES.txt
11
+ twelveten_cli.egg-info/dependency_links.txt
12
+ twelveten_cli.egg-info/entry_points.txt
13
+ twelveten_cli.egg-info/requires.txt
14
+ twelveten_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ 1210 = twelveten_cli.main:cli
@@ -0,0 +1,7 @@
1
+ click>=8.1
2
+ rich>=13.0
3
+ prompt-toolkit>=3.0
4
+ boto3>=1.34
5
+ httpx>=0.27
6
+ pyjwt>=2.8
7
+ InquirerPy>=0.3
@@ -0,0 +1 @@
1
+ twelveten_cli