eightstatecli 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
escli/__init__.py ADDED
@@ -0,0 +1,837 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ escli — Eightstate CLI
4
+
5
+ A unified CLI for Eightstate AI services. Designed for both human operators
6
+ and AI agents (Claude Code, Codex, etc.).
7
+
8
+ AGENT INSTRUCTIONS:
9
+ - Use --json for all commands to get structured JSON output to stdout
10
+ - Use --quiet to suppress stderr progress messages
11
+ - Auth: set ESCLI_API_KEY env var OR run: escli auth login --key <key>
12
+ - Generate: escli --json --quiet image gen "prompt" -s <size> -o output.png
13
+ - Edit: escli --json --quiet image edit "instruction" -i input.png -o output.png
14
+ - Docs: escli --json --quiet docs get react "useEffect cleanup"
15
+ - Audio: escli --json --quiet audio transcribe file.mp3 --speakers
16
+ - JSON output schema: {"success": bool, ...}
17
+ - Exit codes: 0=success, 1=error, 2=usage
18
+ """
19
+
20
+ __version__ = "0.4.0"
21
+
22
+ import argparse
23
+ import base64
24
+ import json as jsonlib
25
+ import os
26
+ import pathlib
27
+ import sys
28
+ import time
29
+ from datetime import datetime
30
+
31
+ # ─────────────────────────────────────────────────────────────────────
32
+ # Branding
33
+ # ─────────────────────────────────────────────────────────────────────
34
+ BANNER_COMPACT = r"""
35
+ ┌─────────────────────────────────┐
36
+ │ escli v""" + __version__ + r""" │
37
+ │ eightstate command line │
38
+ └─────────────────────────────────┘"""
39
+
40
+ # Box drawing
41
+ BOX_H = "─"; BOX_V = "│"; BOX_TL = "┌"; BOX_TR = "┐"; BOX_BL = "└"; BOX_BR = "┘"
42
+ BOX_T = "├"; BOX_B = "┤"
43
+ DOT = "·"; BULLET = "▸"; CHECK = "✓"; CROSS = "✗"; ARROW = "→"
44
+ BLOCK = "█"; SHADE = "░"; PIPE = "│"
45
+
46
+
47
+ def box(title: str, lines: list[str], width: int = 45) -> str:
48
+ inner = width - 4
49
+ out = [f" {BOX_TL}{BOX_H * (width - 2)}{BOX_TR}",
50
+ f" {BOX_V} {title:<{inner}}{BOX_V}",
51
+ f" {BOX_T}{BOX_H * (width - 2)}{BOX_B}"]
52
+ for line in lines:
53
+ out.append(f" {BOX_V} {line[:inner]:<{inner}}{BOX_V}")
54
+ out.append(f" {BOX_BL}{BOX_H * (width - 2)}{BOX_BR}")
55
+ return "\n".join(out)
56
+
57
+
58
+ # ─────────────────────────────────────────────────────────────────────
59
+ # Paths & Defaults
60
+ # ─────────────────────────────────────────────────────────────────────
61
+ CONFIG_DIR = pathlib.Path(os.environ.get("ESCLI_CONFIG_DIR", pathlib.Path.home() / ".escli"))
62
+ CONFIG_FILE = CONFIG_DIR / "config.json"
63
+ ENDPOINT = "https://ai.eightstate.co/v1"
64
+
65
+ GATE_URL = os.environ.get("ESCLI_GATE_URL", "https://internal.eightstate.co")
66
+
67
+ DEFAULTS = {
68
+ "model": "gpt-image-2", "quality": "high", "size": "1024x1024",
69
+ "format": "png", "out_dir": ".", "timeout": 300,
70
+ }
71
+
72
+ SIZE_ALIASES = {
73
+ "square": "1024x1024", "sq": "1024x1024",
74
+ "landscape": "1536x1024", "land": "1536x1024", "ls": "1536x1024", "wide": "1536x1024",
75
+ "portrait": "1024x1536", "port": "1024x1536", "tall": "1024x1536",
76
+ "1024x1024": "1024x1024", "1536x1024": "1536x1024", "1024x1536": "1024x1536",
77
+ }
78
+
79
+ VALID_QUALITIES = ["low", "medium", "high"]
80
+ VALID_FORMATS = ["png", "jpeg", "jpg", "webp"]
81
+ VALID_BACKGROUNDS = ["auto", "transparent"]
82
+
83
+
84
+ # ─────────────────────────────────────────────────────────────────────
85
+ # Config store
86
+ # ─────────────────────────────────────────────────────────────────────
87
+ def _load_config() -> dict:
88
+ if CONFIG_FILE.exists():
89
+ try:
90
+ return jsonlib.loads(CONFIG_FILE.read_text())
91
+ except (jsonlib.JSONDecodeError, OSError):
92
+ return {}
93
+ return {}
94
+
95
+
96
+ def _save_config(cfg: dict):
97
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
98
+ CONFIG_FILE.write_text(jsonlib.dumps(cfg, indent=2) + "\n")
99
+ CONFIG_FILE.chmod(0o600)
100
+
101
+
102
+ def get_active_profile_name() -> str:
103
+ return _load_config().get("active_profile", "default")
104
+
105
+
106
+ def get_profile(name: str = None) -> dict:
107
+ cfg = _load_config()
108
+ if name is None:
109
+ name = cfg.get("active_profile", "default")
110
+ profile = cfg.get("profiles", {}).get(name, {})
111
+ return {
112
+ "name": name,
113
+ "api_key": profile.get("api_key") or os.environ.get("ESCLI_API_KEY", ""),
114
+ "endpoint": profile.get("endpoint") or os.environ.get("ESCLI_BASE_URL", ENDPOINT),
115
+ "label": profile.get("label", name),
116
+ }
117
+
118
+
119
+ def set_profile(name, api_key, label=None, endpoint=None):
120
+ cfg = _load_config()
121
+ cfg.setdefault("profiles", {})[name] = {
122
+ "api_key": api_key, "endpoint": endpoint or ENDPOINT,
123
+ "label": label or name, "created": datetime.now().isoformat(),
124
+ }
125
+ cfg["active_profile"] = name
126
+ _save_config(cfg)
127
+
128
+
129
+ def delete_profile(name):
130
+ cfg = _load_config()
131
+ profiles = cfg.get("profiles", {})
132
+ if name not in profiles:
133
+ return False
134
+ del profiles[name]
135
+ if cfg.get("active_profile") == name:
136
+ remaining = list(profiles.keys())
137
+ cfg["active_profile"] = remaining[0] if remaining else ""
138
+ _save_config(cfg)
139
+ return True
140
+
141
+
142
+ def list_profiles():
143
+ cfg = _load_config()
144
+ active = cfg.get("active_profile", "default")
145
+ return [{
146
+ "name": n, "label": d.get("label", n),
147
+ "key": f"{d.get('api_key','')[:8]}...{d.get('api_key','')[-4:]}" if len(d.get("api_key","")) > 12 else "***",
148
+ "endpoint": d.get("endpoint", ENDPOINT),
149
+ "active": n == active, "created": d.get("created", ""),
150
+ } for n, d in cfg.get("profiles", {}).items()]
151
+
152
+
153
+ def resolve_api_key(args):
154
+ if getattr(args, "_explicit_api_key", None):
155
+ return args._explicit_api_key
156
+ p = get_profile()
157
+ return p["api_key"] or os.environ.get("ESCLI_API_KEY", "")
158
+
159
+
160
+ def resolve_endpoint(args):
161
+ if getattr(args, "_explicit_base_url", None):
162
+ return args._explicit_base_url
163
+ return get_profile().get("endpoint", ENDPOINT)
164
+
165
+
166
+ # ─────────────────────────────────────────────────────────────────────
167
+ # Helpers
168
+ # ─────────────────────────────────────────────────────────────────────
169
+ def slugify(text, max_len=40):
170
+ slug = "".join(c if c.isalnum() or c == " " else "" for c in text.lower().strip())
171
+ return "-".join(slug.split())[:max_len] or "image"
172
+
173
+ def auto_filename(prompt, ext="png"):
174
+ return f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{slugify(prompt)}.{ext}"
175
+
176
+ def log(msg, quiet=False):
177
+ if not quiet:
178
+ print(msg, file=sys.stderr)
179
+
180
+ def resolve_size(value):
181
+ key = value.lower().strip()
182
+ if key in SIZE_ALIASES:
183
+ return SIZE_ALIASES[key]
184
+ print(f" {CROSS} invalid size: {value}", file=sys.stderr)
185
+ sys.exit(2)
186
+
187
+ def mask_key(key):
188
+ return f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
189
+
190
+ def require_openai():
191
+ try:
192
+ from openai import OpenAI
193
+ return OpenAI
194
+ except ImportError:
195
+ print(f" {CROSS} missing: openai SDK. pip install openai", file=sys.stderr)
196
+ sys.exit(1)
197
+
198
+ def require_httpx():
199
+ try:
200
+ import httpx
201
+ return httpx
202
+ except ImportError:
203
+ print(f" {CROSS} missing: httpx. pip install httpx", file=sys.stderr)
204
+ sys.exit(1)
205
+
206
+ def open_file(path):
207
+ import subprocess
208
+ if sys.platform == "darwin":
209
+ subprocess.run(["open", str(path)], check=False)
210
+ elif sys.platform == "linux":
211
+ subprocess.run(["xdg-open", str(path)], check=False)
212
+ elif sys.platform == "win32":
213
+ os.startfile(str(path))
214
+
215
+ def ensure_authed(args):
216
+ api_key, base_url = resolve_api_key(args), resolve_endpoint(args)
217
+ if not api_key:
218
+ log(f"\n {CROSS} not authenticated\n {ARROW} run: escli auth login\n", getattr(args, 'quiet', False))
219
+ sys.exit(1)
220
+ return api_key, base_url
221
+
222
+ def validate_key(api_key, endpoint, timeout=15):
223
+ OpenAI = require_openai()
224
+ try:
225
+ client = OpenAI(base_url=endpoint, api_key=api_key, timeout=timeout)
226
+ return True, f"{len(client.models.list().data)} models"
227
+ except Exception as e:
228
+ err = str(e)
229
+ if "401" in err or "Unauthorized" in err:
230
+ return False, "invalid key"
231
+ if "403" in err or "Forbidden" in err:
232
+ return False, "not authorized"
233
+ return False, f"connection error: {err[:80]}"
234
+
235
+
236
+ # ─────────────────────────────────────────────────────────────────────
237
+ # Commands
238
+ # ─────────────────────────────────────────────────────────────────────
239
+
240
+ def cmd_auth_login(args):
241
+ # Legacy: --key flag for direct API key login (image proxy)
242
+ if getattr(args, "key", None):
243
+ return _legacy_key_login(args)
244
+
245
+ # Device flow via gate + Clerk
246
+ log("", args.quiet)
247
+ log(BANNER_COMPACT, args.quiet)
248
+ log("", args.quiet)
249
+ log(f" {BULLET} logging in via eightstate...", args.quiet)
250
+
251
+ httpx = require_httpx()
252
+ try:
253
+ resp = httpx.post(f"{GATE_URL}/api/auth/device", timeout=10)
254
+ resp.raise_for_status()
255
+ data = resp.json()
256
+ except Exception as e:
257
+ if args.json: print(jsonlib.dumps({"success": False, "error": f"failed to reach gate: {e}"}))
258
+ else: log(f" {CROSS} failed to reach {GATE_URL}: {e}", args.quiet)
259
+ return 1
260
+
261
+ device_code = data["device_code"]
262
+ user_code = data["user_code"]
263
+ verification_url = data["verification_url"]
264
+ interval = data.get("interval", 5)
265
+
266
+ log(f" {DOT} opening browser...", args.quiet)
267
+ log(f" {DOT} confirm code: {user_code}", args.quiet)
268
+ log("", args.quiet)
269
+
270
+ # Open browser
271
+ import subprocess, webbrowser
272
+ try:
273
+ webbrowser.open(verification_url)
274
+ except Exception:
275
+ log(f" {ARROW} open this URL: {verification_url}", args.quiet)
276
+
277
+ log(f" {SHADE * 30} waiting for auth...", args.quiet)
278
+
279
+ # Poll
280
+ import time as _time
281
+ deadline = _time.time() + 900 # 15 min max
282
+ while _time.time() < deadline:
283
+ _time.sleep(interval)
284
+ try:
285
+ poll_resp = httpx.post(
286
+ f"{GATE_URL}/api/auth/poll",
287
+ json={"device_code": device_code},
288
+ timeout=10,
289
+ )
290
+ poll_data = poll_resp.json()
291
+ except Exception:
292
+ continue
293
+
294
+ if poll_data.get("status") == "authorized":
295
+ cli_token = poll_data["token"]
296
+ # Store the CLI token
297
+ profile_name = getattr(args, "profile", None) or "default"
298
+ cfg = _load_config()
299
+ cfg.setdefault("profiles", {})[profile_name] = {
300
+ "cli_token": cli_token,
301
+ "endpoint": GATE_URL,
302
+ "label": getattr(args, "label", None) or profile_name,
303
+ "created": datetime.now().isoformat(),
304
+ }
305
+ cfg["active_profile"] = profile_name
306
+ _save_config(cfg)
307
+
308
+ if args.json:
309
+ print(jsonlib.dumps({"success": True, "profile": profile_name, "gate": GATE_URL}))
310
+ else:
311
+ log("", args.quiet)
312
+ log(box(f"{CHECK} authenticated", [
313
+ f"profile {profile_name}",
314
+ f"gate {GATE_URL}",
315
+ f"config {CONFIG_FILE}",
316
+ ]), args.quiet)
317
+ log("", args.quiet)
318
+ return 0
319
+
320
+ if poll_data.get("status") == "expired":
321
+ if args.json: print(jsonlib.dumps({"success": False, "error": "session expired"}))
322
+ else: log(f" {CROSS} session expired. try again.", args.quiet)
323
+ return 1
324
+
325
+ if args.json: print(jsonlib.dumps({"success": False, "error": "timed out"}))
326
+ else: log(f" {CROSS} timed out waiting for auth.", args.quiet)
327
+ return 1
328
+
329
+
330
+ def _legacy_key_login(args):
331
+ """Legacy login with a direct API key (for image proxy)."""
332
+ endpoint = getattr(args, "endpoint", None) or ENDPOINT
333
+ api_key = args.key
334
+ profile_name = getattr(args, "profile", None) or "default"
335
+ label = getattr(args, "label", None) or profile_name
336
+
337
+ log(f" {DOT} validating against {endpoint}...", args.quiet)
338
+ ok, msg = validate_key(api_key, endpoint)
339
+ if not ok:
340
+ if args.json: print(jsonlib.dumps({"success": False, "error": msg}))
341
+ else: log(f" {CROSS} {msg}", args.quiet)
342
+ return 1
343
+
344
+ set_profile(profile_name, api_key, label=label, endpoint=endpoint)
345
+ masked = mask_key(api_key)
346
+ if args.json:
347
+ print(jsonlib.dumps({"success": True, "profile": profile_name, "key": masked,
348
+ "endpoint": endpoint, "models": msg, "config_path": str(CONFIG_FILE)}))
349
+ else:
350
+ log("", args.quiet)
351
+ log(box(f"{CHECK} authenticated", [f"profile {profile_name}", f"key {masked}",
352
+ f"models {msg}", f"config {CONFIG_FILE}"]), args.quiet)
353
+ log("", args.quiet)
354
+ return 0
355
+
356
+
357
+ def cmd_auth_logout(args):
358
+ profile_name = getattr(args, "profile", None) or get_active_profile_name()
359
+ if getattr(args, "all", False):
360
+ if CONFIG_FILE.exists(): CONFIG_FILE.unlink()
361
+ if args.json: print(jsonlib.dumps({"success": True, "message": "all profiles removed"}))
362
+ else: log(f" {CHECK} all profiles removed", args.quiet)
363
+ return 0
364
+ deleted = delete_profile(profile_name)
365
+ if args.json: print(jsonlib.dumps({"success": True, "profile": profile_name, "deleted": deleted}))
366
+ else: log(f" {CHECK if deleted else CROSS} profile '{profile_name}' {'removed' if deleted else 'not found'}", args.quiet)
367
+ return 0
368
+
369
+
370
+ def cmd_auth_status(args):
371
+ profile = get_profile()
372
+ has_key = bool(profile["api_key"])
373
+ if args.json:
374
+ r = {"authenticated": has_key, "profile": profile["name"], "endpoint": profile["endpoint"],
375
+ "config_path": str(CONFIG_FILE)}
376
+ if has_key: r["key"] = mask_key(profile["api_key"])
377
+ print(jsonlib.dumps(r))
378
+ else:
379
+ if has_key:
380
+ print(box(f"{CHECK} authenticated", [f"profile {profile['name']}",
381
+ f"key {mask_key(profile['api_key'])}",
382
+ f"endpoint {profile['endpoint']}"]))
383
+ else: print(f"\n {CROSS} not authenticated\n {ARROW} run: escli auth login\n")
384
+ return 0
385
+
386
+
387
+ def cmd_auth_profiles(args):
388
+ profiles = list_profiles()
389
+ if args.json:
390
+ print(jsonlib.dumps({"success": True, "profiles": profiles, "count": len(profiles)})); return 0
391
+ if not profiles: print(f" {CROSS} no profiles. run: escli auth login"); return 0
392
+ print(f"\n {'':2}{'PROFILE':<16} {'KEY':<24} {'ENDPOINT'}")
393
+ print(f" {BOX_H * 65}")
394
+ for p in profiles:
395
+ print(f" {BULLET if p['active'] else ' '} {p['name']:<15} {p['key']:<24} {p['endpoint']}")
396
+ print(f"\n {len(profiles)} profile(s) {DOT} active: {get_active_profile_name()}\n")
397
+ return 0
398
+
399
+
400
+ def cmd_auth_switch(args):
401
+ target = args.profile_name
402
+ cfg = _load_config()
403
+ profiles = cfg.get("profiles", {})
404
+ if target not in profiles:
405
+ if args.json: print(jsonlib.dumps({"success": False, "error": f"profile '{target}' not found"}))
406
+ else: log(f" {CROSS} profile '{target}' not found", args.quiet)
407
+ return 1
408
+ cfg["active_profile"] = target; _save_config(cfg)
409
+ if args.json: print(jsonlib.dumps({"success": True, "active_profile": target}))
410
+ else: log(f" {CHECK} switched {ARROW} {target}", args.quiet)
411
+ return 0
412
+
413
+
414
+ def cmd_image_generate(args):
415
+ api_key, base_url = ensure_authed(args)
416
+ OpenAI = require_openai()
417
+ client = OpenAI(base_url=base_url, api_key=api_key, timeout=args.timeout)
418
+ prompt = " ".join(args.prompt)
419
+ if not prompt: print(f" {CROSS} prompt required", file=sys.stderr); sys.exit(1)
420
+
421
+ size = resolve_size(args.size)
422
+ ext = args.format if args.format != "jpg" else "jpeg"
423
+ out_ext = args.format if args.format != "jpeg" else "jpg"
424
+ out_path = pathlib.Path(args.output) if args.output else pathlib.Path(args.out_dir) / auto_filename(prompt, out_ext)
425
+ out_path.parent.mkdir(parents=True, exist_ok=True)
426
+
427
+ log("", args.quiet)
428
+ log(f" {BULLET} prompt {prompt}", args.quiet)
429
+ log(f" {DOT} size {size} {DOT} quality {args.quality} {DOT} format {args.format}", args.quiet)
430
+ log(f" {DOT} model {args.model}", args.quiet)
431
+ log(f" {DOT} output {out_path}", args.quiet)
432
+ log("", args.quiet)
433
+ log(f" {SHADE * 30} generating...", args.quiet)
434
+
435
+ extra_body = {}
436
+ if args.format and args.format != "png": extra_body["output_format"] = ext
437
+ if args.background: extra_body["background"] = args.background
438
+ if args.compression is not None: extra_body["output_compression"] = args.compression
439
+ if args.moderation: extra_body["moderation"] = args.moderation
440
+
441
+ t0 = time.time()
442
+ try:
443
+ kwargs = dict(model=args.model, prompt=prompt, size=size, quality=args.quality, response_format="b64_json")
444
+ if extra_body: kwargs["extra_body"] = extra_body
445
+ resp = client.images.generate(**kwargs)
446
+ except Exception as e:
447
+ if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
448
+ else: log(f" {CROSS} {e}", args.quiet)
449
+ sys.exit(1)
450
+
451
+ elapsed = round(time.time() - t0, 1)
452
+ raw = base64.b64decode(resp.data[0].b64_json)
453
+ out_path.write_bytes(raw)
454
+ revised = getattr(resp.data[0], "revised_prompt", None)
455
+ kb = len(raw) / 1024
456
+
457
+ if args.json:
458
+ result = {"success": True, "path": str(out_path.resolve()), "size_bytes": len(raw),
459
+ "elapsed_seconds": elapsed, "prompt": prompt, "model": args.model,
460
+ "image_size": size, "quality": args.quality}
461
+ if revised: result["revised_prompt"] = revised
462
+ print(jsonlib.dumps(result))
463
+ else:
464
+ log(f" {BLOCK * 30} done", args.quiet)
465
+ log("", args.quiet)
466
+ log(f" {CHECK} {out_path}", args.quiet)
467
+ log(f" {elapsed}s {DOT} {kb:.0f}kb {DOT} {size}", args.quiet)
468
+ if revised: log(f" revised: {revised[:80]}...", args.quiet)
469
+ log("", args.quiet)
470
+ if args.quiet: print(str(out_path.resolve()))
471
+
472
+ if args.open: open_file(out_path)
473
+ return 0
474
+
475
+
476
+ def cmd_image_edit(args):
477
+ api_key, base_url = ensure_authed(args)
478
+ httpx = require_httpx()
479
+ prompt = " ".join(args.prompt)
480
+ if not prompt: print(f" {CROSS} prompt required", file=sys.stderr); sys.exit(1)
481
+
482
+ input_path = pathlib.Path(args.input)
483
+ if not input_path.exists(): print(f" {CROSS} not found: {input_path}", file=sys.stderr); sys.exit(1)
484
+
485
+ size = resolve_size(args.size)
486
+ img_b64 = base64.b64encode(input_path.read_bytes()).decode()
487
+ mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
488
+ ".webp": "image/webp"}.get(input_path.suffix.lower(), "image/png")
489
+ out_ext = args.format if args.format != "jpeg" else "jpg"
490
+ out_path = pathlib.Path(args.output) if args.output else pathlib.Path(args.out_dir) / auto_filename(prompt, out_ext)
491
+ out_path.parent.mkdir(parents=True, exist_ok=True)
492
+
493
+ log("", args.quiet)
494
+ log(f" {BULLET} input {input_path}", args.quiet)
495
+ log(f" {BULLET} prompt {prompt}", args.quiet)
496
+ log(f" {DOT} size {size} {DOT} quality {args.quality}", args.quiet)
497
+ log(f" {DOT} output {out_path}", args.quiet)
498
+ log("", args.quiet)
499
+ log(f" {SHADE * 30} editing...", args.quiet)
500
+
501
+ body = {"model": args.model, "prompt": prompt,
502
+ "images": [{"image_url": f"data:{mime};base64,{img_b64}"}],
503
+ "size": size, "quality": args.quality, "response_format": "b64_json"}
504
+ ext = args.format if args.format != "jpg" else "jpeg"
505
+ if args.format and args.format != "png": body["output_format"] = ext
506
+ if args.compression is not None: body["output_compression"] = args.compression
507
+ if args.fidelity: body["input_fidelity"] = args.fidelity
508
+
509
+ t0 = time.time()
510
+ try:
511
+ r = httpx.post(f"{base_url}/images/edits",
512
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
513
+ json=body, timeout=args.timeout)
514
+ r.raise_for_status(); data = r.json()
515
+ except Exception as e:
516
+ if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
517
+ else: log(f" {CROSS} {e}", args.quiet)
518
+ sys.exit(1)
519
+
520
+ elapsed = round(time.time() - t0, 1)
521
+ raw = base64.b64decode(data["data"][0]["b64_json"])
522
+ out_path.write_bytes(raw); kb = len(raw) / 1024
523
+
524
+ if args.json:
525
+ print(jsonlib.dumps({"success": True, "path": str(out_path.resolve()), "size_bytes": len(raw),
526
+ "elapsed_seconds": elapsed, "prompt": prompt, "input": str(input_path.resolve()),
527
+ "model": args.model, "image_size": size, "quality": args.quality}))
528
+ else:
529
+ log(f" {BLOCK * 30} done", args.quiet); log("", args.quiet)
530
+ log(f" {CHECK} {out_path}", args.quiet)
531
+ log(f" {elapsed}s {DOT} {kb:.0f}kb {DOT} {size}", args.quiet); log("", args.quiet)
532
+ if args.quiet: print(str(out_path.resolve()))
533
+
534
+ if args.open: open_file(out_path)
535
+ return 0
536
+
537
+
538
+ def cmd_models(args):
539
+ api_key, base_url = ensure_authed(args)
540
+ OpenAI = require_openai()
541
+ try:
542
+ models = OpenAI(base_url=base_url, api_key=api_key, timeout=30).models.list()
543
+ except Exception as e:
544
+ if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
545
+ else: print(f" {CROSS} {e}", file=sys.stderr)
546
+ sys.exit(1)
547
+ model_list = sorted([m.id for m in models.data])
548
+ if args.json:
549
+ print(jsonlib.dumps({"success": True, "models": model_list, "count": len(model_list)}))
550
+ else:
551
+ print(f"\n {'MODEL':<40}"); print(f" {BOX_H * 40}")
552
+ for m in model_list: print(f" {m}")
553
+ print(f"\n {len(model_list)} models available\n")
554
+ return 0
555
+
556
+
557
+ def cmd_version(args):
558
+ if args.json: print(jsonlib.dumps({"version": __version__, "endpoint": ENDPOINT}))
559
+ else: print(BANNER_COMPACT)
560
+ return 0
561
+
562
+
563
+ # ─────────────────────────────────────────────────────────────────────
564
+ # Parser — with full agent-readable help on every subcommand
565
+ # ─────────────────────────────────────────────────────────────────────
566
+ def build_parser():
567
+ F = argparse.RawDescriptionHelpFormatter
568
+
569
+ root = argparse.ArgumentParser(prog="escli", description=BANNER_COMPACT + "\n", formatter_class=F, epilog=f"""quickstart:
570
+ escli auth login {DOT} authenticate
571
+ escli image gen "a sunset over the ocean" {DOT} generate
572
+ escli models {DOT} list models
573
+
574
+ examples:
575
+ escli i g "landscape" -s wide -q high --open
576
+ escli i e "repaint as watercolor" -i photo.jpg --open
577
+ escli --json --quiet i g "a logo" {DOT} agent mode
578
+
579
+ shortcuts:
580
+ i g {ARROW} image generate a {ARROW} auth
581
+ i e {ARROW} image edit m {ARROW} models
582
+
583
+ sizes:
584
+ square {PIPE} sq 1024x1024 (default)
585
+ landscape {PIPE} wide 1536x1024
586
+ portrait {PIPE} tall 1024x1536
587
+
588
+ agent usage:
589
+ {DOT} always pass --json for structured output to stdout
590
+ {DOT} pass --quiet to suppress stderr progress messages
591
+ {DOT} set ESCLI_API_KEY env var to skip auth login
592
+ {DOT} output JSON schema:
593
+ {{"success": true, "path": "/abs/path.png", "size_bytes": 123456,
594
+ "elapsed_seconds": 18.4, "prompt": "...", "model": "gpt-image-2",
595
+ "image_size": "1024x1024", "quality": "high"}}
596
+ {DOT} on error: {{"success": false, "error": "message"}}
597
+ {DOT} exit codes: 0=success, 1=error, 2=usage
598
+
599
+ env vars:
600
+ ESCLI_API_KEY API key (overrides stored profile)
601
+ ESCLI_BASE_URL API endpoint (default: {ENDPOINT})
602
+ ESCLI_IMAGE_MODEL Default model (default: gpt-image-2)
603
+ ESCLI_IMAGE_QUALITY Default quality: low | medium | high (default: high)
604
+ ESCLI_IMAGE_SIZE Default size (default: 1024x1024)
605
+ ESCLI_IMAGE_FORMAT Default format: png | jpeg | webp (default: png)
606
+ ESCLI_OUT_DIR Output directory (default: .)
607
+ ESCLI_TIMEOUT Timeout in seconds (default: 300)
608
+ ESCLI_CONFIG_DIR Config directory (default: ~/.escli)
609
+
610
+ config: ~/.escli/config.json (600 permissions, owner-only)
611
+ """)
612
+ root.add_argument("-v", "--version", action="store_true", help="Show version")
613
+ root.add_argument("--json", action="store_true", default=False, help="Structured JSON output (agent-friendly)")
614
+ root.add_argument("--quiet", action="store_true", default=False, help="Suppress stderr progress, print only result to stdout")
615
+ root.add_argument("--describe", action="store_true", default=False, help="Machine-readable JSON manifest of all commands (for agent discovery)")
616
+ root.add_argument("--base-url", dest="_explicit_base_url", default=None, help=argparse.SUPPRESS)
617
+ root.add_argument("--api-key", dest="_explicit_api_key", default=None, help=argparse.SUPPRESS)
618
+ root.add_argument("--timeout", type=int, default=int(os.environ.get("ESCLI_TIMEOUT", "300")), help=argparse.SUPPRESS)
619
+
620
+ subs = root.add_subparsers(dest="command", metavar="command")
621
+
622
+ # ── auth ──────────────────────────────────────────────────────────
623
+ auth_p = subs.add_parser("auth", aliases=["a"], help="Authentication and profiles", formatter_class=F,
624
+ epilog=f"""subcommands:
625
+ login {DOT} authenticate with API key
626
+ logout {DOT} remove credentials
627
+ status {DOT} show current profile (alias: whoami)
628
+ profiles {DOT} list all profiles (alias: ls)
629
+ switch {DOT} switch active profile (alias: use)
630
+
631
+ agent examples:
632
+ escli auth login --key sk-xxx {DOT} non-interactive login
633
+ escli --json auth status {DOT} check if authenticated
634
+ ESCLI_API_KEY=sk-xxx escli --json models {DOT} skip login entirely
635
+ """)
636
+ auth_subs = auth_p.add_subparsers(dest="auth_command", metavar="subcommand")
637
+
638
+ login_p = auth_subs.add_parser("login", aliases=["l"], help="Authenticate with an API key", formatter_class=F,
639
+ epilog=f"""examples:
640
+ escli auth login {DOT} interactive prompt
641
+ escli auth login --key sk-xxx {DOT} non-interactive (agents/CI)
642
+ escli auth login -k sk-xxx -p work {DOT} named profile
643
+ echo sk-xxx | escli auth login {DOT} pipe key from stdin
644
+
645
+ agent: escli --json auth login --key sk-xxx
646
+ {ARROW} {{"success": true, "profile": "default", "key": "sk-xxx...xxx", "models": "28 models"}}
647
+ """)
648
+ login_p.add_argument("--key", "-k", default=None, help="API key (omit for interactive prompt)")
649
+ login_p.add_argument("--profile", "-p", default="default", help="Profile name (default: default)")
650
+ login_p.add_argument("--label", default=None, help="Human-readable label for this profile")
651
+ login_p.add_argument("--endpoint", default=None, help=f"API endpoint (default: {ENDPOINT})")
652
+ login_p.set_defaults(func=cmd_auth_login)
653
+
654
+ logout_p = auth_subs.add_parser("logout", help="Remove stored credentials")
655
+ logout_p.add_argument("--profile", "-p", default=None, help="Profile to remove (default: active)")
656
+ logout_p.add_argument("--all", action="store_true", help="Remove ALL profiles")
657
+ logout_p.set_defaults(func=cmd_auth_logout)
658
+
659
+ auth_subs.add_parser("status", aliases=["s", "whoami"], help="Show current auth status").set_defaults(func=cmd_auth_status)
660
+ auth_subs.add_parser("profiles", aliases=["ls", "list"], help="List all profiles").set_defaults(func=cmd_auth_profiles)
661
+
662
+ switch_p = auth_subs.add_parser("switch", aliases=["use"], help="Switch active profile")
663
+ switch_p.add_argument("profile_name", help="Profile to activate")
664
+ switch_p.set_defaults(func=cmd_auth_switch)
665
+
666
+ # ── image ─────────────────────────────────────────────────────────
667
+ image_p = subs.add_parser("image", aliases=["img", "i"], help="Image generation and editing", formatter_class=F,
668
+ epilog=f"""subcommands:
669
+ generate (gen, g) {DOT} generate image from text prompt
670
+ edit (e) {DOT} edit existing image with instruction
671
+
672
+ quick reference:
673
+ escli i g "prompt" -s wide -q high --open {DOT} generate landscape
674
+ escli i e "instruction" -i img.png --open {DOT} edit image
675
+
676
+ sizes: square(1024x1024) landscape(1536x1024) portrait(1024x1536)
677
+ aliases: sq, wide, tall, land, port, ls
678
+ """)
679
+ image_subs = image_p.add_subparsers(dest="image_command", metavar="subcommand")
680
+
681
+ def add_image_flags(p, include_input=False):
682
+ p.add_argument("prompt", nargs="+", help="Text prompt describing desired image")
683
+ p.add_argument("-s", "--size", default=os.environ.get("ESCLI_IMAGE_SIZE", DEFAULTS["size"]),
684
+ help="Image size: square|sq|landscape|wide|ls|portrait|tall|port or WxH (default: square = 1024x1024)")
685
+ p.add_argument("-q", "--quality", default=os.environ.get("ESCLI_IMAGE_QUALITY", DEFAULTS["quality"]),
686
+ choices=VALID_QUALITIES, help="Quality: low (fast, ~15s) | medium (~25s) | high (best, ~40s) (default: high)")
687
+ p.add_argument("-f", "--format", default=os.environ.get("ESCLI_IMAGE_FORMAT", DEFAULTS["format"]),
688
+ choices=VALID_FORMATS, help="Output format: png | jpeg | jpg | webp (default: png)")
689
+ p.add_argument("-m", "--model", default=os.environ.get("ESCLI_IMAGE_MODEL", DEFAULTS["model"]),
690
+ help="Model to use (default: gpt-image-2)")
691
+ p.add_argument("-o", "--output", default=None,
692
+ help="Output file path. If omitted, auto-generates: YYYYMMDD-HHMMSS-slugified-prompt.png")
693
+ p.add_argument("-d", "--out-dir", default=os.environ.get("ESCLI_OUT_DIR", DEFAULTS["out_dir"]),
694
+ help="Output directory when -o not set (default: current dir)")
695
+ p.add_argument("--open", action="store_true", help="Open image in default viewer after generating")
696
+ p.add_argument("--compression", type=int, default=None, metavar="0-100",
697
+ help="Output compression level for jpeg/webp (0=min, 100=max)")
698
+ p.add_argument("--moderation", default=None, choices=["auto", "low"],
699
+ help="Content moderation: auto (default) | low (permissive)")
700
+ if include_input:
701
+ p.add_argument("-i", "--input", required=True,
702
+ help="Path to input image file to edit (png, jpg, webp)")
703
+ p.add_argument("--fidelity", default=None, choices=["low", "high"],
704
+ help="How closely to preserve the input image: low (more creative) | high (more faithful)")
705
+
706
+ gen_p = image_subs.add_parser("generate", aliases=["gen", "g"], help="Generate an image from a text prompt",
707
+ formatter_class=F, epilog=f"""sizes (any of these work for -s):
708
+ square, sq {ARROW} 1024x1024 (default)
709
+ landscape, wide, ls {ARROW} 1536x1024 (good for wallpapers, banners)
710
+ portrait, tall, port {ARROW} 1024x1536 (good for phone mockups, posters)
711
+ 1024x1024 {ARROW} also accepts raw WxH values
712
+
713
+ quality:
714
+ low {DOT} fastest (~15s), lower detail
715
+ medium {DOT} balanced (~25s)
716
+ high {DOT} best quality (~40s), most detail
717
+
718
+ examples:
719
+ escli i g "a sunset over the ocean"
720
+ escli i g "oil painting of a mountain" -s wide -q high --open
721
+ escli i g "phone app mockup" -s portrait -o mockup.png
722
+ escli i g "logo design" -f webp --compression 80
723
+ escli i g "product photo" -s sq -q high -d ./output/
724
+
725
+ agent examples:
726
+ escli --json --quiet i g "a logo" -s sq -q low -o /tmp/logo.png
727
+
728
+ stdout JSON on success:
729
+ {{"success": true, "path": "/abs/path/logo.png", "size_bytes": 123456,
730
+ "elapsed_seconds": 18.4, "prompt": "a logo", "model": "gpt-image-2",
731
+ "image_size": "1024x1024", "quality": "low",
732
+ "revised_prompt": "A simple clean logo design..."}}
733
+
734
+ stdout JSON on error:
735
+ {{"success": false, "error": "error message"}}
736
+ """)
737
+ add_image_flags(gen_p)
738
+ gen_p.add_argument("--background", default=None, choices=VALID_BACKGROUNDS,
739
+ help="Background: auto (default) | transparent (removes background)")
740
+ gen_p.set_defaults(func=cmd_image_generate)
741
+
742
+ edit_p = image_subs.add_parser("edit", aliases=["e"], help="Edit an existing image with an instruction",
743
+ formatter_class=F, epilog=f"""how it works:
744
+ Takes an input image (-i) and applies the prompt as an edit instruction.
745
+ The model understands the image content and modifies it accordingly.
746
+
747
+ examples:
748
+ escli i e "make it snow" -i summer.jpg --open
749
+ escli i e "repaint as oil painting" -i photo.jpg -q high -o painted.png
750
+ escli i e "remove the background" -i product.png
751
+ escli i e "add sunglasses to the person" -i portrait.jpg -s sq
752
+
753
+ agent example:
754
+ escli --json --quiet i e "make it blue" -i input.png -o output.png
755
+
756
+ stdout JSON on success:
757
+ {{"success": true, "path": "/abs/path/output.png", "size_bytes": 123456,
758
+ "elapsed_seconds": 22.7, "prompt": "make it blue",
759
+ "input": "/abs/path/input.png", "model": "gpt-image-2",
760
+ "image_size": "1024x1024", "quality": "high"}}
761
+
762
+ supported input formats: png, jpg, jpeg, webp
763
+ """)
764
+ add_image_flags(edit_p, include_input=True)
765
+ edit_p.set_defaults(func=cmd_image_edit)
766
+
767
+ # ── models ────────────────────────────────────────────────────────
768
+ subs.add_parser("models", aliases=["m"], help="List available models", formatter_class=F,
769
+ epilog=f"""lists all models available on the API endpoint.
770
+
771
+ agent example:
772
+ escli --json models
773
+ {ARROW} {{"success": true, "models": ["gpt-image-2", "gpt-5.4-mini", ...], "count": 28}}
774
+ """).set_defaults(func=cmd_models)
775
+
776
+ # ── docs (Context7) ──────────────────────────────────────────────
777
+ from .commands.docs import register as register_docs
778
+ register_docs(subs)
779
+
780
+ # ── audio (AssemblyAI) ───────────────────────────────────────────
781
+ from .commands.audio import register as register_audio
782
+ register_audio(subs)
783
+
784
+ # ── search + fetch (Parallel.ai) ────────────────────────────────
785
+ from .commands.search import register as register_search
786
+ register_search(subs)
787
+
788
+ # ── research (Parallel Task API) ────────────────────────────────
789
+ from .commands.research import register as register_research
790
+ register_research(subs)
791
+
792
+ # ── social (Tavily) ─────────────────────────────────────────────
793
+ from .commands.social import register as register_social
794
+ register_social(subs)
795
+
796
+ # ── usage (gate analytics) ──────────────────────────────────────
797
+ from .commands.usage import register as register_usage
798
+ register_usage(subs)
799
+
800
+ # ── version ───────────────────────────────────────────────────────
801
+ subs.add_parser("version", help="Show version info").set_defaults(func=cmd_version)
802
+
803
+ return root
804
+
805
+
806
+ # ─────────────────────────────────────────────────────────────────────
807
+ # Main
808
+ # ─────────────────────────────────────────────────────────────────────
809
+ def main():
810
+ parser = build_parser()
811
+ args = parser.parse_args()
812
+ for attr in ("json", "quiet"):
813
+ if not hasattr(args, attr): setattr(args, attr, False)
814
+
815
+ # Self-description: machine-readable manifest for agent discovery
816
+ if getattr(args, "describe", False):
817
+ from .services.describe import generate_manifest
818
+ import json as _json
819
+ print(_json.dumps(generate_manifest(parser), indent=2))
820
+ return 0
821
+
822
+ if getattr(args, "version", False) and not args.command: return cmd_version(args)
823
+ if not args.command: parser.print_help(); return 0
824
+ if args.command in ("auth", "a") and not getattr(args, "auth_command", None):
825
+ print(f" usage: escli auth <login{PIPE}logout{PIPE}status{PIPE}profiles{PIPE}switch>", file=sys.stderr); return 2
826
+ if args.command in ("image", "img", "i") and not getattr(args, "image_command", None):
827
+ print(f" usage: escli image <generate{PIPE}edit>", file=sys.stderr); return 2
828
+ if args.command in ("docs", "d") and not getattr(args, "docs_command", None):
829
+ print(f" usage: escli docs <search{PIPE}get{PIPE}fetch>", file=sys.stderr); return 2
830
+ if args.command in ("audio", "au") and not getattr(args, "audio_command", None):
831
+ print(f" usage: escli audio <transcribe{PIPE}status{PIPE}get{PIPE}list>", file=sys.stderr); return 2
832
+ if hasattr(args, "func"): return args.func(args)
833
+ parser.print_help(); return 0
834
+
835
+
836
+ if __name__ == "__main__":
837
+ sys.exit(main() or 0)