@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.
- package/.claude/skills/browser/SKILL.md +8 -0
- package/.claude/skills/homepage/SKILL.md +4 -3
- package/.claude/skills/kol-outreach/SKILL.md +371 -0
- package/.claude/skills/kol-outreach/template/campaign/CONFIG.md +60 -0
- package/.claude/skills/kol-outreach/template/campaign/CONVERSATIONS/.gitkeep +0 -0
- package/.claude/skills/kol-outreach/template/campaign/KOLS.md +6 -0
- package/.claude/skills/kol-outreach/template/campaign/PROGRESS.md +3 -0
- package/.claude/skills/kol-outreach/template/campaign/TEMPLATES.md +88 -0
- package/.claude/skills/kol-outreach/template/campaign/assets/.gitkeep +0 -0
- package/.claude/skills/kol-outreach/template/merchant/BRAND.md +36 -0
- package/.claude/skills/kol-outreach/template/merchant/CAMPAIGNS.md +6 -0
- package/.claude/skills/kol-outreach/template/merchant/MERCHANT_LIMITS.md +16 -0
- package/.claude/skills/kol-outreach/template/merchant/PROGRESS.md +4 -0
- package/.claude/skills/kol-outreach/template/merchant/README.md +20 -0
- package/.claude/skills/video-clone/SKILL.md +125 -217
- package/.claude/skills/video-clone/assets/phase-state-template.json +11 -0
- package/.claude/skills/video-clone/references/ffmpeg-commands.md +31 -34
- package/.claude/skills/video-clone/references/gate-enforcement.md +144 -0
- package/.claude/skills/video-clone/references/kling-api.md +75 -75
- package/.claude/skills/video-clone/references/url-parsing.md +32 -13
- package/.claude/skills/video-clone/scripts/_confirm.py +96 -0
- package/.claude/skills/video-clone/scripts/_confirm_test.py +125 -0
- package/.claude/skills/video-clone/scripts/_gate.py +162 -0
- package/.claude/skills/video-clone/scripts/_gate_e2e_test.py +226 -0
- package/.claude/skills/video-clone/scripts/_gate_test.py +148 -0
- package/.claude/skills/video-clone/scripts/_project.py +56 -0
- package/.claude/skills/video-clone/scripts/analyze_source.py +113 -0
- package/.claude/skills/video-clone/scripts/analyze_source_test.py +52 -0
- package/.claude/skills/video-clone/scripts/assemble.py +106 -0
- package/.claude/skills/video-clone/scripts/confirm.py +12 -0
- package/.claude/skills/video-clone/scripts/edit_first_frame.py +66 -0
- package/.claude/skills/video-clone/scripts/extract_frames.py +108 -0
- package/.claude/skills/video-clone/scripts/gen_video.py +59 -0
- package/.claude/skills/video-clone/scripts/init_project.py +103 -0
- package/.claude/skills/video-clone/scripts/init_project_test.py +106 -0
- package/.claude/skills/video-clone/scripts/kling_generate.py +262 -0
- package/.claude/skills/video-clone/scripts/kling_generate_test.py +191 -0
- package/.claude/skills/video-clone/scripts/preflight.py +102 -0
- package/.claude/skills/video-clone/scripts/preview.py +208 -0
- package/.claude/skills/video-clone/scripts/preview_test.py +169 -0
- package/.claude/skills/video-clone/scripts/save_workflow.py +129 -0
- package/.claude/skills/video-clone/scripts/save_workflow_test.py +106 -0
- package/.claude/skills/video-clone/scripts/status.py +202 -0
- package/.claude/skills/video-clone/scripts/status_test.py +174 -0
- 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())
|