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.
- twelveten_cli-0.1.0/PKG-INFO +27 -0
- twelveten_cli-0.1.0/pyproject.toml +45 -0
- twelveten_cli-0.1.0/setup.cfg +4 -0
- twelveten_cli-0.1.0/twelveten_cli/__init__.py +2 -0
- twelveten_cli-0.1.0/twelveten_cli/api.py +165 -0
- twelveten_cli-0.1.0/twelveten_cli/auth.py +109 -0
- twelveten_cli-0.1.0/twelveten_cli/config.py +8 -0
- twelveten_cli-0.1.0/twelveten_cli/main.py +364 -0
- twelveten_cli-0.1.0/twelveten_cli/repl.py +387 -0
- twelveten_cli-0.1.0/twelveten_cli/ui.py +108 -0
- twelveten_cli-0.1.0/twelveten_cli.egg-info/PKG-INFO +27 -0
- twelveten_cli-0.1.0/twelveten_cli.egg-info/SOURCES.txt +14 -0
- twelveten_cli-0.1.0/twelveten_cli.egg-info/dependency_links.txt +1 -0
- twelveten_cli-0.1.0/twelveten_cli.egg-info/entry_points.txt +2 -0
- twelveten_cli-0.1.0/twelveten_cli.egg-info/requires.txt +7 -0
- twelveten_cli-0.1.0/twelveten_cli.egg-info/top_level.txt +1 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
twelveten_cli
|