@optima-chat/optima-agent 0.8.91 → 0.8.93

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.
Files changed (45) hide show
  1. package/.claude/skills/browser/SKILL.md +8 -0
  2. package/.claude/skills/homepage/SKILL.md +4 -3
  3. package/.claude/skills/kol-outreach/SKILL.md +371 -0
  4. package/.claude/skills/kol-outreach/template/campaign/CONFIG.md +60 -0
  5. package/.claude/skills/kol-outreach/template/campaign/CONVERSATIONS/.gitkeep +0 -0
  6. package/.claude/skills/kol-outreach/template/campaign/KOLS.md +6 -0
  7. package/.claude/skills/kol-outreach/template/campaign/PROGRESS.md +3 -0
  8. package/.claude/skills/kol-outreach/template/campaign/TEMPLATES.md +88 -0
  9. package/.claude/skills/kol-outreach/template/campaign/assets/.gitkeep +0 -0
  10. package/.claude/skills/kol-outreach/template/merchant/BRAND.md +36 -0
  11. package/.claude/skills/kol-outreach/template/merchant/CAMPAIGNS.md +6 -0
  12. package/.claude/skills/kol-outreach/template/merchant/MERCHANT_LIMITS.md +16 -0
  13. package/.claude/skills/kol-outreach/template/merchant/PROGRESS.md +4 -0
  14. package/.claude/skills/kol-outreach/template/merchant/README.md +20 -0
  15. package/.claude/skills/video-clone/SKILL.md +125 -217
  16. package/.claude/skills/video-clone/assets/phase-state-template.json +11 -0
  17. package/.claude/skills/video-clone/references/ffmpeg-commands.md +31 -34
  18. package/.claude/skills/video-clone/references/gate-enforcement.md +144 -0
  19. package/.claude/skills/video-clone/references/kling-api.md +75 -75
  20. package/.claude/skills/video-clone/references/url-parsing.md +32 -13
  21. package/.claude/skills/video-clone/scripts/_confirm.py +96 -0
  22. package/.claude/skills/video-clone/scripts/_confirm_test.py +125 -0
  23. package/.claude/skills/video-clone/scripts/_gate.py +162 -0
  24. package/.claude/skills/video-clone/scripts/_gate_e2e_test.py +226 -0
  25. package/.claude/skills/video-clone/scripts/_gate_test.py +148 -0
  26. package/.claude/skills/video-clone/scripts/_project.py +56 -0
  27. package/.claude/skills/video-clone/scripts/analyze_source.py +113 -0
  28. package/.claude/skills/video-clone/scripts/analyze_source_test.py +52 -0
  29. package/.claude/skills/video-clone/scripts/assemble.py +106 -0
  30. package/.claude/skills/video-clone/scripts/confirm.py +12 -0
  31. package/.claude/skills/video-clone/scripts/edit_first_frame.py +66 -0
  32. package/.claude/skills/video-clone/scripts/extract_frames.py +108 -0
  33. package/.claude/skills/video-clone/scripts/gen_video.py +59 -0
  34. package/.claude/skills/video-clone/scripts/init_project.py +103 -0
  35. package/.claude/skills/video-clone/scripts/init_project_test.py +106 -0
  36. package/.claude/skills/video-clone/scripts/kling_generate.py +262 -0
  37. package/.claude/skills/video-clone/scripts/kling_generate_test.py +191 -0
  38. package/.claude/skills/video-clone/scripts/preflight.py +102 -0
  39. package/.claude/skills/video-clone/scripts/preview.py +208 -0
  40. package/.claude/skills/video-clone/scripts/preview_test.py +169 -0
  41. package/.claude/skills/video-clone/scripts/save_workflow.py +129 -0
  42. package/.claude/skills/video-clone/scripts/save_workflow_test.py +106 -0
  43. package/.claude/skills/video-clone/scripts/status.py +202 -0
  44. package/.claude/skills/video-clone/scripts/status_test.py +174 -0
  45. package/package.json +2 -1
@@ -0,0 +1,106 @@
1
+ """Tests for init_project.py.
2
+
3
+ Uses GEN_OUTPUT_ROOT env override to isolate the tests from the real
4
+ gen-output/ directory.
5
+
6
+ Run: python scripts/init_project_test.py
7
+ """
8
+ import json
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ SCRIPTS = Path(__file__).parent
17
+ INIT = SCRIPTS / "init_project.py"
18
+
19
+
20
+ def _run(root: Path, *args: str) -> subprocess.CompletedProcess:
21
+ env = os.environ.copy()
22
+ env["GEN_OUTPUT_ROOT"] = str(root)
23
+ return subprocess.run(
24
+ [sys.executable, str(INIT), *args],
25
+ capture_output=True, text=True, env=env,
26
+ )
27
+
28
+
29
+ def test_creates_full_structure():
30
+ with tempfile.TemporaryDirectory() as tmp:
31
+ root = Path(tmp)
32
+ r = _run(root, "--name", "myproj", "--task-type", "video_clone")
33
+ assert r.returncode == 0, f"init failed: {r.stderr}"
34
+ base = root / "video-clone" / "myproj"
35
+ assert (base / "source").is_dir()
36
+ assert (base / "frames").is_dir()
37
+ assert (base / "videos").is_dir()
38
+ state_file = base / ".state" / "phase.json"
39
+ assert state_file.is_file()
40
+ state = json.loads(state_file.read_text(encoding="utf-8"))
41
+ assert state["project"] == "myproj"
42
+ assert state["task_type"] == "video_clone"
43
+ assert state["created_at"] is not None
44
+ # Single-gate model: only preview_confirmed, no plan/prompt/frame gates
45
+ assert "preview_confirmed" in state["gates"], (
46
+ f"Expected single-gate schema with 'preview_confirmed', got: {list(state['gates'])}"
47
+ )
48
+ assert state["gates"]["preview_confirmed"]["status"] is False
49
+ # Old 3-gate keys must NOT exist
50
+ for old_gate in ("plan_confirmed", "prompt_confirmed", "frame_confirmed"):
51
+ assert old_gate not in state["gates"], (
52
+ f"Old gate '{old_gate}' still present in template"
53
+ )
54
+
55
+
56
+ def test_creates_video_gen_project():
57
+ with tempfile.TemporaryDirectory() as tmp:
58
+ root = Path(tmp)
59
+ r = _run(root, "--name", "genproj", "--task-type", "video_gen")
60
+ assert r.returncode == 0, f"init failed: {r.stderr}"
61
+ state_file = root / "video-clone" / "genproj" / ".state" / "phase.json"
62
+ state = json.loads(state_file.read_text(encoding="utf-8"))
63
+ assert state["task_type"] == "video_gen"
64
+ assert "preview_confirmed" in state["gates"]
65
+
66
+
67
+ def test_rejects_duplicate():
68
+ with tempfile.TemporaryDirectory() as tmp:
69
+ root = Path(tmp)
70
+ _run(root, "--name", "dup", "--task-type", "video_clone")
71
+ r = _run(root, "--name", "dup", "--task-type", "video_clone")
72
+ assert r.returncode == 1, (
73
+ f"expected exit 1 on duplicate, got {r.returncode}\n"
74
+ f"stdout: {r.stdout}\nstderr: {r.stderr}"
75
+ )
76
+
77
+
78
+ def test_rejects_unknown_task_type():
79
+ with tempfile.TemporaryDirectory() as tmp:
80
+ r = _run(Path(tmp), "--name", "x", "--task-type", "nonsense")
81
+ assert r.returncode != 0
82
+
83
+
84
+ TESTS = [
85
+ ("creates full structure", test_creates_full_structure),
86
+ ("creates video_gen project", test_creates_video_gen_project),
87
+ ("rejects duplicate", test_rejects_duplicate),
88
+ ("rejects unknown task type", test_rejects_unknown_task_type),
89
+ ]
90
+
91
+
92
+ def main() -> int:
93
+ failed = 0
94
+ for name, fn in TESTS:
95
+ try:
96
+ fn()
97
+ print(f"PASS {name}")
98
+ except Exception as e:
99
+ failed += 1
100
+ print(f"FAIL {name}: {type(e).__name__}: {e}")
101
+ print(f"\n{len(TESTS) - failed}/{len(TESTS)} passed")
102
+ return 0 if failed == 0 else 1
103
+
104
+
105
+ if __name__ == "__main__":
106
+ sys.exit(main())
@@ -0,0 +1,262 @@
1
+ """Phase 3: generate video with audio via the Optima generation backend.
2
+
3
+ The generation backend routes to Kling 3.0 (PiAPI) when provider="piapi".
4
+ Billing is automatic — the server's billing middleware deducts credits from
5
+ the user's Optima wallet at submit time and refunds on task failure.
6
+
7
+ Usage:
8
+ python scripts/kling_generate.py --project <name> --frame <path> \
9
+ [--duration 10] [--aspect-ratio 9:16] [--mode std] \
10
+ [--cfg-scale 0.5] [--no-audio]
11
+
12
+ Auth & API URL discovery (matches @optima-chat/gen-cli convention):
13
+ Token:
14
+ 1. OPTIMA_TOKEN env var
15
+ 2. ~/.optima/token.json ("access_token" field)
16
+ Generation API URL:
17
+ 1. GENERATION_API_URL env var
18
+ 2. ~/.optima/token.json ("env" field) → ci/stage/prod mapping
19
+ 3. default: prod (https://gen-api.optima.onl)
20
+
21
+ A logged-in user (`optima login`) has both automatically; no server-side
22
+ env injection is required.
23
+
24
+ Pipeline:
25
+ 1. Gate: requires preview_confirmed.
26
+ 2. POST <GEN_URL>/api/video/generate with provider="piapi" + base64 frame.
27
+ Server-side billing middleware pre-deducts credits here.
28
+ 3. Poll GET <GEN_URL>/api/task/{id} every 10s until completed/failed.
29
+ 4. Download result_url to <project>/videos/ (3-attempt retry).
30
+
31
+ Reads prompt from <project>/prompt.md. Writes output with a versioned filename.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import base64
37
+ import json
38
+ import os
39
+ import sys
40
+ import time
41
+ from pathlib import Path
42
+
43
+ from _gate import require_gate
44
+ from _project import resolve_project, next_version, append_log
45
+
46
+ try:
47
+ import requests # type: ignore
48
+ except ImportError: # pragma: no cover
49
+ requests = None
50
+
51
+ POLL_INTERVAL_S = 10
52
+ POLL_TIMEOUT_S = 15 * 60 # 15 min (server side uses 10 min, leave 5 min headroom)
53
+ DOWNLOAD_RETRIES = 3
54
+ DOWNLOAD_BACKOFF_S = 5
55
+
56
+ TOKEN_FILE = Path.home() / ".optima" / "token.json"
57
+ API_URLS = {
58
+ "prod": "https://gen-api.optima.onl",
59
+ "stage": "https://gen-api.stage.optima.onl",
60
+ }
61
+
62
+
63
+ def _load_token_file() -> dict | None:
64
+ """Read ~/.optima/token.json if present. Returns parsed dict or None."""
65
+ if not TOKEN_FILE.is_file():
66
+ return None
67
+ try:
68
+ data = json.loads(TOKEN_FILE.read_text(encoding="utf-8"))
69
+ except (OSError, json.JSONDecodeError):
70
+ return None
71
+ if not data.get("access_token"):
72
+ return None
73
+ return data
74
+
75
+
76
+ def _get_token() -> str:
77
+ """Resolve the Optima auth token.
78
+
79
+ Order matches @optima-chat/gen-cli: env var > token.json. Fail fast with
80
+ a login hint if neither is available.
81
+ """
82
+ env_token = os.environ.get("OPTIMA_TOKEN", "").strip()
83
+ if env_token:
84
+ return env_token
85
+ data = _load_token_file()
86
+ if data:
87
+ return data["access_token"]
88
+ print(
89
+ "ERROR: Optima token not found. Set OPTIMA_TOKEN or run `optima login`.",
90
+ file=sys.stderr,
91
+ )
92
+ sys.exit(1)
93
+
94
+
95
+ def _get_gen_api_url() -> str:
96
+ """Resolve the generation service base URL.
97
+
98
+ Order matches @optima-chat/gen-cli: GENERATION_API_URL > token.json env
99
+ field > prod default.
100
+ """
101
+ override = os.environ.get("GENERATION_API_URL", "").strip()
102
+ if override:
103
+ return override.rstrip("/")
104
+ data = _load_token_file()
105
+ env = (data or {}).get("env") or "prod"
106
+ return API_URLS.get(env, API_URLS["prod"])
107
+
108
+
109
+ def _auth_header(token: str) -> str:
110
+ """Accept either 'Bearer xxx' or a raw token; normalize to Bearer form."""
111
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
112
+
113
+
114
+ def _submit(gen_url: str, auth: str, prompt: str, frame_b64: str,
115
+ duration: int, aspect: str, mode: str,
116
+ cfg: float, audio: bool) -> str:
117
+ """POST /api/video/generate with provider='piapi'. Returns backend task_id.
118
+
119
+ On billing failure the server returns 402 (INSUFFICIENT_CREDITS) or
120
+ 403 (PLAN_RESTRICTED); both are surfaced as actionable errors.
121
+ """
122
+ payload = {
123
+ "provider": "piapi",
124
+ "image": frame_b64,
125
+ "prompt": prompt,
126
+ "duration": duration,
127
+ "aspect_ratio": aspect,
128
+ "mode": mode,
129
+ "cfg_scale": float(cfg),
130
+ "enable_audio": audio,
131
+ }
132
+ r = requests.post(
133
+ f"{gen_url}/api/video/generate",
134
+ headers={"Authorization": auth, "Content-Type": "application/json"},
135
+ json=payload, timeout=60,
136
+ )
137
+ if r.status_code == 402:
138
+ raise RuntimeError(f"INSUFFICIENT_CREDITS: {r.json().get('error', {}).get('message', 'not enough credits')}")
139
+ if r.status_code == 403:
140
+ raise RuntimeError(f"PLAN_RESTRICTED: {r.json().get('error', {}).get('message', 'plan does not include video generation')}")
141
+ r.raise_for_status()
142
+ return r.json()["task_id"]
143
+
144
+
145
+ def _poll(gen_url: str, auth: str, task_id: str) -> tuple[str, dict]:
146
+ """Poll /api/task/{id} until completed or failed. Returns (result_url, result_meta).
147
+
148
+ On client-side timeout the task may still complete server-side (credits
149
+ already deducted). The task_id is included in the timeout message so the
150
+ caller can re-check later via GET /api/task/<id>.
151
+ """
152
+ start = time.monotonic()
153
+ while True:
154
+ if time.monotonic() - start > POLL_TIMEOUT_S:
155
+ raise RuntimeError(
156
+ f"polling timed out after {POLL_TIMEOUT_S}s (task_id={task_id}). "
157
+ f"Task may still complete server-side — query "
158
+ f"GET /api/task/{task_id} to check status and retrieve the result."
159
+ )
160
+ r = requests.get(
161
+ f"{gen_url}/api/task/{task_id}",
162
+ headers={"Authorization": auth}, timeout=60,
163
+ )
164
+ r.raise_for_status()
165
+ d = r.json()
166
+ status = d.get("status", "")
167
+ if status == "completed":
168
+ url = d.get("result_url")
169
+ if not url:
170
+ raise RuntimeError(
171
+ f"task {task_id} completed but result_url missing from response"
172
+ )
173
+ return url, d.get("result_meta") or {}
174
+ if status == "failed":
175
+ raise RuntimeError(d.get("error_message") or f"task {task_id} failed without error message")
176
+ print(f" status: {status} — polling again in {POLL_INTERVAL_S}s")
177
+ time.sleep(POLL_INTERVAL_S)
178
+
179
+
180
+ def _download(url: str, out: Path) -> None:
181
+ last_err = None
182
+ for attempt in range(DOWNLOAD_RETRIES):
183
+ try:
184
+ r = requests.get(url, timeout=300)
185
+ r.raise_for_status()
186
+ out.write_bytes(r.content)
187
+ return
188
+ except Exception as e:
189
+ last_err = e
190
+ print(f" download attempt {attempt + 1}/{DOWNLOAD_RETRIES} failed: {e}")
191
+ time.sleep(DOWNLOAD_BACKOFF_S)
192
+ raise RuntimeError(f"download failed after {DOWNLOAD_RETRIES} attempts: {last_err}")
193
+
194
+
195
+ def main() -> int:
196
+ ap = argparse.ArgumentParser()
197
+ ap.add_argument("--project", required=True)
198
+ ap.add_argument("--frame", required=True, help="confirmed first frame")
199
+ ap.add_argument("--duration", type=int, default=10, choices=[5, 10])
200
+ ap.add_argument("--aspect-ratio", default="9:16")
201
+ ap.add_argument("--mode", default="std", choices=["std", "pro"])
202
+ ap.add_argument("--cfg-scale", type=float, default=0.5)
203
+ ap.add_argument("--no-audio", action="store_true")
204
+ args = ap.parse_args()
205
+
206
+ project_dir = resolve_project(args.project)
207
+ require_gate(project_dir, "preview_confirmed")
208
+
209
+ if requests is None:
210
+ print("ERROR: the 'requests' package is required. Install: pip install requests",
211
+ file=sys.stderr)
212
+ return 1
213
+
214
+ gen_url = _get_gen_api_url()
215
+ auth = _auth_header(_get_token())
216
+
217
+ frame = Path(args.frame)
218
+ if not frame.is_file():
219
+ print(f"ERROR: frame not found at {frame}", file=sys.stderr)
220
+ return 1
221
+
222
+ prompt_path = project_dir / "prompt.md"
223
+ if not prompt_path.is_file():
224
+ print(f"ERROR: {prompt_path} missing. Write prompt.md first.", file=sys.stderr)
225
+ return 1
226
+ prompt = prompt_path.read_text(encoding="utf-8").strip()
227
+
228
+ frame_b64 = base64.b64encode(frame.read_bytes()).decode()
229
+
230
+ print("Submitting video generation task …")
231
+ try:
232
+ task_id = _submit(
233
+ gen_url, auth, prompt, frame_b64,
234
+ duration=args.duration, aspect=args.aspect_ratio, mode=args.mode,
235
+ cfg=args.cfg_scale, audio=not args.no_audio,
236
+ )
237
+ except RuntimeError as e:
238
+ print(f"ERROR: {e}", file=sys.stderr)
239
+ return 1
240
+ print(f" task_id: {task_id}")
241
+
242
+ print("Polling for completion …")
243
+ try:
244
+ result_url, result_meta = _poll(gen_url, auth, task_id)
245
+ except RuntimeError as e:
246
+ print(f"ERROR: {e}", file=sys.stderr)
247
+ return 1
248
+ print(f" result_url: {result_url}")
249
+ if result_meta:
250
+ print(f" result_meta: {result_meta}")
251
+
252
+ out = next_version(project_dir / "videos", f"video_{args.duration}s_", ".mp4")
253
+ print(f"Downloading → {out}")
254
+ _download(result_url, out)
255
+
256
+ print(f"\nDone: {out}")
257
+ append_log(project_dir, f"video_generated_with_audio → {out.name} (task {task_id})")
258
+ return 0
259
+
260
+
261
+ if __name__ == "__main__":
262
+ sys.exit(main())
@@ -0,0 +1,191 @@
1
+ """Tests for the pure helper functions in kling_generate.py.
2
+
3
+ No network. We do not exercise _submit / _poll / _download — those are
4
+ I/O-bound and covered by end-to-end staging runs. Unit tests here keep the
5
+ helper contracts honest so refactors don't silently break the auth-header
6
+ normalization or the token / API-URL discovery order.
7
+
8
+ Run: python scripts/kling_generate_test.py
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+ from unittest import mock
19
+
20
+ SCRIPTS_DIR = Path(__file__).parent
21
+ sys.path.insert(0, str(SCRIPTS_DIR))
22
+
23
+ import kling_generate # noqa: E402
24
+
25
+
26
+ # ---------- _auth_header ----------
27
+
28
+ def test_auth_header_adds_bearer_prefix():
29
+ assert kling_generate._auth_header("xyz123") == "Bearer xyz123"
30
+
31
+
32
+ def test_auth_header_preserves_existing_bearer():
33
+ assert kling_generate._auth_header("Bearer xyz123") == "Bearer xyz123"
34
+
35
+
36
+ def test_auth_header_is_case_insensitive_on_existing_prefix():
37
+ assert kling_generate._auth_header("bearer xyz123") == "bearer xyz123"
38
+ assert kling_generate._auth_header("BEARER xyz123") == "BEARER xyz123"
39
+
40
+
41
+ # ---------- _get_token ----------
42
+
43
+ def test_get_token_prefers_env_var(tmp_token_file):
44
+ tmp_token_file.write_text(
45
+ json.dumps({"access_token": "from-file", "env": "prod"}), encoding="utf-8"
46
+ )
47
+ os.environ["OPTIMA_TOKEN"] = "from-env"
48
+ try:
49
+ assert kling_generate._get_token() == "from-env"
50
+ finally:
51
+ os.environ.pop("OPTIMA_TOKEN", None)
52
+
53
+
54
+ def test_get_token_falls_back_to_file(tmp_token_file):
55
+ os.environ.pop("OPTIMA_TOKEN", None)
56
+ tmp_token_file.write_text(
57
+ json.dumps({"access_token": "file-xyz", "env": "stage"}), encoding="utf-8"
58
+ )
59
+ assert kling_generate._get_token() == "file-xyz"
60
+
61
+
62
+ def test_get_token_exits_when_neither_source_has_token(tmp_token_file):
63
+ os.environ.pop("OPTIMA_TOKEN", None)
64
+ # tmp_token_file does not exist (fixture creates parent, caller decides)
65
+ # Here: don't create the file — _load_token_file should return None
66
+ code = (
67
+ "import sys; sys.path.insert(0, r'{d}'); import kling_generate; "
68
+ "kling_generate._get_token()"
69
+ ).format(d=str(SCRIPTS_DIR))
70
+ env = {**os.environ}
71
+ env.pop("OPTIMA_TOKEN", None)
72
+ env["HOME"] = str(tmp_token_file.parent.parent) # .../.optima/token.json → HOME = .../
73
+ env["USERPROFILE"] = env["HOME"]
74
+ result = subprocess.run(
75
+ [sys.executable, "-c", code], capture_output=True, text=True, env=env,
76
+ )
77
+ assert result.returncode == 1
78
+ assert "optima login" in result.stderr or "OPTIMA_TOKEN" in result.stderr
79
+
80
+
81
+ # ---------- _get_gen_api_url ----------
82
+
83
+ def test_get_gen_api_url_env_override_wins(tmp_token_file):
84
+ tmp_token_file.write_text(
85
+ json.dumps({"access_token": "t", "env": "stage"}), encoding="utf-8"
86
+ )
87
+ os.environ["GENERATION_API_URL"] = "https://custom.example/api"
88
+ try:
89
+ assert kling_generate._get_gen_api_url() == "https://custom.example/api"
90
+ finally:
91
+ os.environ.pop("GENERATION_API_URL", None)
92
+
93
+
94
+ def test_get_gen_api_url_trims_trailing_slash():
95
+ os.environ["GENERATION_API_URL"] = "https://custom.example/"
96
+ try:
97
+ assert kling_generate._get_gen_api_url() == "https://custom.example"
98
+ finally:
99
+ os.environ.pop("GENERATION_API_URL", None)
100
+
101
+
102
+ def test_get_gen_api_url_reads_env_from_token_file(tmp_token_file):
103
+ os.environ.pop("GENERATION_API_URL", None)
104
+ tmp_token_file.write_text(
105
+ json.dumps({"access_token": "t", "env": "stage"}), encoding="utf-8"
106
+ )
107
+ assert kling_generate._get_gen_api_url() == "https://gen-api.stage.optima.onl"
108
+
109
+
110
+ def test_get_gen_api_url_defaults_to_prod_when_no_file(tmp_token_file):
111
+ os.environ.pop("GENERATION_API_URL", None)
112
+ # tmp_token_file not created → should default to prod
113
+ assert kling_generate._get_gen_api_url() == "https://gen-api.optima.onl"
114
+
115
+
116
+ # ---------- fixture ----------
117
+
118
+ class _TmpToken:
119
+ """Manage a throwaway ~/.optima/token.json by monkey-patching the module constant."""
120
+ def __init__(self):
121
+ self.tmp = tempfile.TemporaryDirectory()
122
+ self.path = Path(self.tmp.name) / ".optima" / "token.json"
123
+ self.path.parent.mkdir(parents=True)
124
+ self._orig = kling_generate.TOKEN_FILE
125
+ kling_generate.TOKEN_FILE = self.path
126
+
127
+ def write_text(self, *a, **kw):
128
+ self.path.write_text(*a, **kw)
129
+
130
+ @property
131
+ def parent(self):
132
+ return self.path.parent
133
+
134
+ def cleanup(self):
135
+ kling_generate.TOKEN_FILE = self._orig
136
+ self.tmp.cleanup()
137
+
138
+
139
+ def _with_tmp_token_file(fn):
140
+ """Decorator-ish: pass a fresh _TmpToken to the test, then clean up."""
141
+ def wrapped():
142
+ h = _TmpToken()
143
+ try:
144
+ return fn(h)
145
+ finally:
146
+ h.cleanup()
147
+ return wrapped
148
+
149
+
150
+ # Rebind each test to wrap with the fixture
151
+ test_get_token_prefers_env_var = _with_tmp_token_file(test_get_token_prefers_env_var)
152
+ test_get_token_falls_back_to_file = _with_tmp_token_file(test_get_token_falls_back_to_file)
153
+ test_get_token_exits_when_neither_source_has_token = _with_tmp_token_file(test_get_token_exits_when_neither_source_has_token)
154
+ test_get_gen_api_url_env_override_wins = _with_tmp_token_file(test_get_gen_api_url_env_override_wins)
155
+ test_get_gen_api_url_reads_env_from_token_file = _with_tmp_token_file(test_get_gen_api_url_reads_env_from_token_file)
156
+ test_get_gen_api_url_defaults_to_prod_when_no_file = _with_tmp_token_file(test_get_gen_api_url_defaults_to_prod_when_no_file)
157
+
158
+
159
+ TESTS = [
160
+ ("auth_header adds Bearer prefix", test_auth_header_adds_bearer_prefix),
161
+ ("auth_header preserves Bearer", test_auth_header_preserves_existing_bearer),
162
+ ("auth_header case-insensitive on prefix", test_auth_header_is_case_insensitive_on_existing_prefix),
163
+ ("get_token: env wins over file", test_get_token_prefers_env_var),
164
+ ("get_token: falls back to token.json", test_get_token_falls_back_to_file),
165
+ ("get_token: exits 1 when neither source", test_get_token_exits_when_neither_source_has_token),
166
+ ("get_gen_api_url: env override wins", test_get_gen_api_url_env_override_wins),
167
+ ("get_gen_api_url: trims trailing slash", test_get_gen_api_url_trims_trailing_slash),
168
+ ("get_gen_api_url: reads env from token.json", test_get_gen_api_url_reads_env_from_token_file),
169
+ ("get_gen_api_url: defaults to prod", test_get_gen_api_url_defaults_to_prod_when_no_file),
170
+ ]
171
+
172
+
173
+ def main() -> int:
174
+ failed = 0
175
+ for name, fn in TESTS:
176
+ try:
177
+ fn()
178
+ print(f"PASS {name}")
179
+ except AssertionError as e:
180
+ failed += 1
181
+ print(f"FAIL {name}: {e}")
182
+ except Exception as e:
183
+ failed += 1
184
+ print(f"ERROR {name}: {type(e).__name__}: {e}")
185
+ print()
186
+ print(f"{len(TESTS) - failed}/{len(TESTS)} passed")
187
+ return 0 if failed == 0 else 1
188
+
189
+
190
+ if __name__ == "__main__":
191
+ sys.exit(main())
@@ -0,0 +1,102 @@
1
+ """Pre-flight environment check for the video-clone skill.
2
+
3
+ Verifies the external dependencies the executor scripts will need:
4
+ - `gen` CLI — gen image / gen video (routed through generation backend)
5
+ - `ffmpeg` / `ffprobe`
6
+ - Optima auth — either OPTIMA_TOKEN env var or ~/.optima/token.json
7
+ (matches @optima-chat/gen-cli convention)
8
+ - Python >= 3.10
9
+
10
+ Exits 0 if all checks pass, 1 otherwise. On failure, prints a human-readable
11
+ list of missing items and how to fix each one.
12
+
13
+ This script is safe to run any time — it only reads env and probes binaries.
14
+ No state, no side effects.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ TOKEN_FILE = Path.home() / ".optima" / "token.json"
26
+
27
+
28
+ def _check_binary(name: str, fix_hint: str) -> tuple[bool, str]:
29
+ path = shutil.which(name)
30
+ if path is None:
31
+ return False, f"{name}: NOT FOUND. {fix_hint}"
32
+ # probe --version for extra confidence (non-blocking if version flag missing)
33
+ try:
34
+ r = subprocess.run(
35
+ [name, "--version"], capture_output=True, text=True, timeout=5
36
+ )
37
+ head = (r.stdout or r.stderr).splitlines()[0] if (r.stdout or r.stderr) else ""
38
+ except Exception:
39
+ head = ""
40
+ return True, f"{name}: OK ({path}) {head}".strip()
41
+
42
+
43
+ def _check_optima_auth() -> tuple[bool, str]:
44
+ """Token comes from OPTIMA_TOKEN env var OR ~/.optima/token.json (gen-cli convention)."""
45
+ if os.environ.get("OPTIMA_TOKEN"):
46
+ return True, "Optima token: OK (source=env OPTIMA_TOKEN)"
47
+ if TOKEN_FILE.is_file():
48
+ try:
49
+ data = json.loads(TOKEN_FILE.read_text(encoding="utf-8"))
50
+ if data.get("access_token"):
51
+ env = data.get("env", "prod")
52
+ return True, f"Optima token: OK (source={TOKEN_FILE}, env={env})"
53
+ except (OSError, json.JSONDecodeError):
54
+ pass
55
+ return (
56
+ False,
57
+ f"Optima token: NOT FOUND. Run `optima login` or set OPTIMA_TOKEN env var.",
58
+ )
59
+
60
+
61
+ def _check_python() -> tuple[bool, str]:
62
+ v = sys.version_info
63
+ ok = (v.major, v.minor) >= (3, 10)
64
+ msg = f"python: {v.major}.{v.minor}.{v.micro}"
65
+ if not ok:
66
+ msg += " — NEED >= 3.10"
67
+ return ok, msg + (" OK" if ok else "")
68
+
69
+
70
+ CHECKS = [
71
+ lambda: _check_python(),
72
+ lambda: _check_binary(
73
+ "gen",
74
+ "Install the gen CLI and ensure it is on PATH. (gen image / gen video)",
75
+ ),
76
+ lambda: _check_binary("ffmpeg", "Install ffmpeg and ensure it is on PATH."),
77
+ lambda: _check_binary("ffprobe", "Install ffmpeg (bundles ffprobe)."),
78
+ lambda: _check_optima_auth(),
79
+ ]
80
+
81
+
82
+ def main() -> int:
83
+ results = [check() for check in CHECKS]
84
+ missing = [msg for ok, msg in results if not ok]
85
+ passing = [msg for ok, msg in results if ok]
86
+
87
+ print("== video-clone preflight ==")
88
+ for msg in passing:
89
+ print(f" [OK] {msg}")
90
+ for msg in missing:
91
+ print(f" [FAIL] {msg}")
92
+ print()
93
+
94
+ if missing:
95
+ print(f"{len(missing)} check(s) failed. Fix the above and re-run.")
96
+ return 1
97
+ print("All checks passed.")
98
+ return 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ sys.exit(main())