shotfable 0.1.0a1__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.
shotfable/SKILL.md ADDED
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: shotfable
3
+ description: Make short films from one sentence with the ShotFable API through the `shotfable` CLI — quote, create, wait, approve checkpoints (humans only), retake shots, and download the clean video + SRT. Use when a user asks to generate a ShotFable film, check a film's status, recover an interrupted run, or call any ShotFable API operation from a terminal.
4
+ ---
5
+
6
+ # ShotFable CLI (`shotfable`)
7
+
8
+ `shotfable` is the official client of the ShotFable API: one command per API operation, the same API key,
9
+ the same Idempotency-Key rules. Locate this file with `shotfable skill path`; list every command with
10
+ `shotfable commands` (machine-readable: command → operationId, method, path, args).
11
+
12
+ ## Output contract
13
+
14
+ - **stdout = exactly one JSON envelope.** Never merge stdout and stderr (`2>&1`).
15
+ `{"ok": bool, "code": 0 | "<error_type>", "message": str, "request_id": str|null, "data": {...},
16
+ "error": {"type", "retryable", "user_action_required", "hint"}}` — `error` only when `ok` is false.
17
+ - **stderr = events**, one per line: `[HH:MM:SS] shotfable_event={json}`. Types include
18
+ `idempotency_key`, `film_created`, `film_status`, `film_event`, `job_status`, `file_ready`, `poll_retry`.
19
+ Recovery handles (film id, Idempotency-Key, a `recover`/`retry` command) are written **before** the
20
+ long part starts. If you persist logs, save stderr separately.
21
+ - The envelope is authoritative; stderr events never replace it.
22
+
23
+ ## Exit codes
24
+
25
+ | exit | code(s) | what to do |
26
+ |---|---|---|
27
+ | 0 | ok | continue |
28
+ | 1 | internal, film_cancelled | keep `request_id`; do not loop |
29
+ | 2 | invalid_request (also usage errors, Idempotency-Key conflict) | fix the request; unknown fields are rejected |
30
+ | 3 | auth_required | ask the user to set a key (see Auth) |
31
+ | 4 | not_found | check the id (`shotfable film list`) |
32
+ | 5 | policy_violation, review_failed | revise the idea/media with the user; not billed |
33
+ | 6 | budget_exceeded, quota_exceeded, region_not_supported | needs the user (top up, wait, region); `shotfable account` |
34
+ | 7 | upstream_unavailable, network_error | retryable — same command, **same Idempotency-Key** |
35
+ | 8 | wait_timeout | only the local wait stopped; run the `hint` command (`shotfable film wait <id>`) |
36
+ | 9 | checkpoint_waiting | **stop**: a human must review and approve |
37
+ | 130 | interrupted | the backend may still run; run the `hint` command |
38
+
39
+ Rules: `error.retryable=true` → retry/recover the same operation. `error.user_action_required` set →
40
+ stop automatic handling and tell the user exactly that action. Always keep `request_id`.
41
+
42
+ ## Typical flow
43
+
44
+ ```bash
45
+ shotfable account # region + credits before spending
46
+ shotfable film quote --idea "…" --duration 60 --style cinematic-real --quality draft
47
+ shotfable film create --idea "…" --duration 60 --style cinematic-real --quality draft \
48
+ --wait --timeout 3600 --download ./out # clean video + SRT (no burned subtitles)
49
+ ```
50
+
51
+ - `film create` without `--wait` returns at once (202) with the film id; follow with `shotfable film wait <id>`.
52
+ - Multiple deliveries: repeat `--aspect` (`--aspect 16:9 --aspect 9:16`).
53
+ - Burned-in subtitles are a billed add-on: only pass `--burn-subtitles` when the user asked for it.
54
+ - Voice: `--voice-source auto|expressive|standard` (product aliases; `expressive` is not offered in `cn`).
55
+ - Full request bodies: `--body req.json` or `--body -`; explicit flags override body fields.
56
+ - Atomic stages (`script|storyboard|review|character|keyframes|shot|music|compose|qa create`,
57
+ `voice lines`, `lipsync create`) return a job; add `--wait` or use `shotfable job wait <job_id>`.
58
+ - Anything else: `shotfable api <METHOD> <path> [--body file] [--query k=v]`.
59
+
60
+ ## Idempotency and recovery (never create duplicates)
61
+
62
+ - Every write prints its `Idempotency-Key` to stderr **before** sending. On `network_error`, a 5xx, or
63
+ an interruption during a write, the result is unknown: rerun **the same command with
64
+ `--idempotency-key <that key>`** (the `retry` field of the event / the error hint). Same key + same body
65
+ returns the original response; never retry a write with a fresh key.
66
+ - Exactly one process polls a film. While a `--wait` process is alive, do not start a second
67
+ `film wait` or `film events` for the same film; do not re-create the film because it is slow.
68
+ - After exit 8 or 130, run the hint (`shotfable film wait <id>`); after a lost process, `shotfable film get <id>`.
69
+
70
+ ## Checkpoints are human decisions
71
+
72
+ With `--mode checkpoint` the film pauses at `storyboard`, `bible` and `final`; the wait exits 9 with
73
+ `data.checkpoint`. Show the user the artifacts (`shotfable film artifacts <id>`), and only after the
74
+ user's explicit decision run `shotfable film approve <id> <checkpoint> [--comment …] --wait`. Never
75
+ approve on the user's behalf, and never treat "the user said continue earlier" as approval of a later checkpoint.
76
+
77
+ ## Auth and secrets
78
+
79
+ - Key precedence: `--api-key` > `SHOTFABLE_API_KEY` > stored profile key.
80
+ - Store a key: `printf '%s\n' "$SHOTFABLE_API_KEY" | shotfable auth login` (stdin; avoid `--token` on argv).
81
+ `shotfable auth status` shows only a masked key (`rs_live_…abcd`), region and balance.
82
+ - Keys live in `${XDG_CONFIG_HOME:-~/.config}/shotfable/credentials.json` (0600). **Never print, echo,
83
+ log, or paste the API key**, never put it in a file you create, and never send it anywhere but the
84
+ API base (the CLI refuses to send it to other hosts, including media download hosts).
85
+
86
+ ## Downloads
87
+
88
+ `shotfable film download <id> --dir D [--aspect 16:9] [--what video,srt,burned,all]` writes
89
+ `<id>_<aspect>.mp4`, `.srt`, `_burned.mp4`, checks every file is non-empty and matches `sha256` when the
90
+ API provides it, and lists absolute paths in `data.files`. Hand the files to the user once.
shotfable/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """shotfable — the official command-line client for the ShotFable API (preview).
2
+
3
+ One command per API operation, the same API key, the same Idempotency-Key semantics.
4
+ Python 3 standard library only.
5
+ """
6
+ __version__ = "0.1.0a1"
7
+ API_VERSION = "v1"
shotfable/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .main import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
shotfable/config.py ADDED
@@ -0,0 +1,158 @@
1
+ """Local state: profiles (config.json) and API keys (credentials.json).
2
+
3
+ Location: ${XDG_CONFIG_HOME:-~/.config}/shotfable/ directory 0700, credentials.json 0600.
4
+ Key precedence: --api-key > SHOTFABLE_API_KEY > the profile's entry in credentials.json.
5
+ Base precedence: --api-base > SHOTFABLE_API_BASE > the profile's api_base > DEFAULT_API_BASE.
6
+ """
7
+ import json
8
+ import os
9
+ import re
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ from .envelope import CliError
14
+
15
+ DEFAULT_API_BASE = "https://api.shotfable.com/v1" # matches openapi servers[0]; production API not serving yet
16
+ DEFAULT_PROFILE = "default"
17
+ KEY_RE = re.compile(r"^rs_(live|test)_[A-Za-z0-9]{8,128}$")
18
+ PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
19
+
20
+
21
+ def config_dir() -> Path:
22
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
23
+ return Path(base) / "shotfable"
24
+
25
+
26
+ def mask_key(key):
27
+ """rs_live_…abcd — never more than the prefix and the last four characters."""
28
+ if not key:
29
+ return None
30
+ m = re.match(r"^(rs_(?:live|test)_)", key)
31
+ prefix = m.group(1) if m else ""
32
+ tail = key[-4:] if len(key) >= len(prefix) + 8 else ""
33
+ return f"{prefix}…{tail}"
34
+
35
+
36
+ def _read_json(path: Path):
37
+ try:
38
+ return json.loads(path.read_text(encoding="utf-8"))
39
+ except FileNotFoundError:
40
+ return {}
41
+ except (OSError, ValueError):
42
+ raise CliError("internal", f"cannot read {path.name} in the shotfable config directory (corrupt JSON?)",
43
+ hint=f"Inspect or remove {path}")
44
+
45
+
46
+ def _write_json_private(path: Path, data):
47
+ d = path.parent
48
+ d.mkdir(parents=True, exist_ok=True)
49
+ os.chmod(d, 0o700)
50
+ fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(d))
51
+ try:
52
+ os.fchmod(fd, 0o600)
53
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
54
+ json.dump(data, fh, indent=2, sort_keys=True)
55
+ fh.write("\n")
56
+ os.replace(tmp, path)
57
+ except BaseException:
58
+ try:
59
+ os.unlink(tmp)
60
+ except OSError:
61
+ pass
62
+ raise
63
+ os.chmod(path, 0o600)
64
+
65
+
66
+ class Store:
67
+ def __init__(self, root: Path = None):
68
+ self.root = root or config_dir()
69
+ self.config_path = self.root / "config.json"
70
+ self.cred_path = self.root / "credentials.json"
71
+
72
+ # ---------------------------------------------------------------- config
73
+ def config(self):
74
+ c = _read_json(self.config_path)
75
+ c.setdefault("current_profile", DEFAULT_PROFILE)
76
+ c.setdefault("profiles", {})
77
+ return c
78
+
79
+ def save_config(self, c):
80
+ _write_json_private(self.config_path, c)
81
+
82
+ def current_profile(self, override=None):
83
+ name = override or os.environ.get("SHOTFABLE_PROFILE") or self.config().get("current_profile") or DEFAULT_PROFILE
84
+ if not PROFILE_RE.match(name):
85
+ raise CliError("invalid_request", f"invalid profile name {name!r}",
86
+ hint="Profile names: letters, digits, '.', '_' or '-' (max 64).")
87
+ return name
88
+
89
+ def profile_settings(self, name):
90
+ return dict(self.config()["profiles"].get(name, {}))
91
+
92
+ def set_profile(self, name, api_base=None):
93
+ c = self.config()
94
+ p = c["profiles"].setdefault(name, {})
95
+ if api_base:
96
+ p["api_base"] = api_base
97
+ self.save_config(c)
98
+
99
+ def use_profile(self, name):
100
+ c = self.config()
101
+ c["current_profile"] = name
102
+ c["profiles"].setdefault(name, {})
103
+ self.save_config(c)
104
+
105
+ def remove_profile(self, name):
106
+ c = self.config()
107
+ existed = c["profiles"].pop(name, None) is not None
108
+ if c.get("current_profile") == name:
109
+ c["current_profile"] = DEFAULT_PROFILE
110
+ self.save_config(c)
111
+ removed_key = self.delete_key(name)
112
+ return existed or removed_key
113
+
114
+ # ---------------------------------------------------------------- credentials
115
+ def stored_key(self, profile):
116
+ if not self.cred_path.exists():
117
+ return None
118
+ return (_read_json(self.cred_path).get("profiles", {}).get(profile) or {}).get("api_key")
119
+
120
+ def save_key(self, profile, key):
121
+ creds = _read_json(self.cred_path) if self.cred_path.exists() else {}
122
+ creds.setdefault("profiles", {})[profile] = {"api_key": key}
123
+ _write_json_private(self.cred_path, creds)
124
+
125
+ def delete_key(self, profile):
126
+ if not self.cred_path.exists():
127
+ return False
128
+ creds = _read_json(self.cred_path)
129
+ existed = creds.get("profiles", {}).pop(profile, None) is not None
130
+ _write_json_private(self.cred_path, creds)
131
+ return existed
132
+
133
+
134
+ def resolve_api_base(store: Store, profile, flag=None):
135
+ base = flag or os.environ.get("SHOTFABLE_API_BASE") or store.profile_settings(profile).get("api_base") or DEFAULT_API_BASE
136
+ base = base.rstrip("/")
137
+ if not re.match(r"^https?://[^\s/]+", base):
138
+ raise CliError("invalid_request", "api base must be an http(s) URL", hint="e.g. --api-base https://api.shotfable.com/v1")
139
+ return base
140
+
141
+
142
+ def resolve_api_key(store: Store, profile, flag=None):
143
+ """Returns (key, source) — source is 'flag' | 'env' | 'profile' | None."""
144
+ if flag:
145
+ return flag, "flag"
146
+ env = os.environ.get("SHOTFABLE_API_KEY")
147
+ if env:
148
+ return env.strip(), "env"
149
+ k = store.stored_key(profile)
150
+ if k:
151
+ return k, "profile"
152
+ return None, None
153
+
154
+
155
+ def validate_key_shape(key):
156
+ if not KEY_RE.match(key or ""):
157
+ raise CliError("invalid_request", "that does not look like a ShotFable API key (expected rs_live_… or rs_test_…)",
158
+ hint="Paste the key exactly as issued; it is never printed back.")
shotfable/envelope.py ADDED
@@ -0,0 +1,169 @@
1
+ """Output contract: exactly one JSON envelope on stdout; events and recovery handles on stderr.
2
+
3
+ Envelope:
4
+ {"ok": bool, "code": 0 | "<error_type>", "message": str, "request_id": str | null,
5
+ "data": {...}, "error": {"type", "retryable", "user_action_required", "hint"}} # error only on failure
6
+ Event (stderr, one line): [HH:MM:SS] shotfable_event={json}
7
+ """
8
+ import json
9
+ import sys
10
+ import time
11
+
12
+ # Stable, documented exit codes. Never renumber; only append.
13
+ EXIT_CODES = {
14
+ "ok": 0,
15
+ "internal": 1,
16
+ "film_cancelled": 1,
17
+ "invalid_request": 2,
18
+ "auth_required": 3,
19
+ "not_found": 4,
20
+ "policy_violation": 5,
21
+ "review_failed": 5,
22
+ "budget_exceeded": 6,
23
+ "quota_exceeded": 6,
24
+ "region_not_supported": 6,
25
+ "upstream_unavailable": 7,
26
+ "network_error": 7,
27
+ "wait_timeout": 8,
28
+ "checkpoint_waiting": 9,
29
+ "interrupted": 130,
30
+ }
31
+
32
+ # The eight server error types (PRODUCT_SPEC §7.1) + the CLI-local ones.
33
+ SERVER_ERROR_TYPES = ("invalid_request", "policy_violation", "review_failed", "budget_exceeded",
34
+ "quota_exceeded", "region_not_supported", "upstream_unavailable", "internal")
35
+ LOCAL_ERROR_TYPES = ("auth_required", "not_found", "network_error", "wait_timeout",
36
+ "checkpoint_waiting", "interrupted", "film_cancelled")
37
+
38
+ RETRYABLE = {"quota_exceeded", "upstream_unavailable", "network_error", "wait_timeout", "interrupted"}
39
+
40
+ USER_ACTION = {
41
+ "auth_required": "provide_api_key",
42
+ "policy_violation": "revise_input",
43
+ "review_failed": "revise_input",
44
+ "budget_exceeded": "top_up_credits",
45
+ "region_not_supported": "change_request_for_region",
46
+ "checkpoint_waiting": "approve_checkpoint",
47
+ }
48
+
49
+ DEFAULT_HINTS = {
50
+ "internal": "Unexpected error. Keep the request_id for support.",
51
+ "invalid_request": "Fix the request (see message); the server runs in strict mode and rejects unknown fields.",
52
+ "auth_required": "Set an API key: `shotfable auth login` (reads the key from stdin), SHOTFABLE_API_KEY, or --api-key.",
53
+ "not_found": "No such resource for this account. List films with `shotfable film list`.",
54
+ "policy_violation": "The input was refused by content policy and was not billed. Change the idea or media and try again.",
55
+ "review_failed": "The script/storyboard did not pass review. Revise the input and create again.",
56
+ "budget_exceeded": "Not enough credits or over budget_cap. Check `shotfable account`, then top up or raise --budget-cap.",
57
+ "quota_exceeded": "Rate or concurrency limit reached. Wait (Retry-After) and retry the same command.",
58
+ "region_not_supported": "Your account region cannot serve this request. Check `shotfable account` for your region.",
59
+ "upstream_unavailable": "Generation is temporarily unavailable; nothing was billed. Retry later with the same Idempotency-Key.",
60
+ "network_error": "Network problem. Reads can simply be retried; for writes the result is unknown — retry with the same Idempotency-Key, never a new one.",
61
+ "wait_timeout": "Only the local wait stopped; the film keeps running. Resume waiting with `shotfable film wait <id>`.",
62
+ "checkpoint_waiting": "A human must review and approve the checkpoint before the film continues.",
63
+ "interrupted": "Interrupted locally; the backend may still be running. Recover with the hint command.",
64
+ "film_cancelled": "The film was cancelled. Create a new film if you still need it.",
65
+ }
66
+
67
+
68
+ def exit_code_for(code):
69
+ if code == 0:
70
+ return 0
71
+ return EXIT_CODES.get(code, 1)
72
+
73
+
74
+ class CliError(Exception):
75
+ """Any failure that should become an error envelope."""
76
+
77
+ def __init__(self, code, message, *, hint=None, retryable=None, user_action_required=None,
78
+ request_id=None, data=None):
79
+ super().__init__(message)
80
+ self.code = code
81
+ self.message = message
82
+ self.hint = hint or DEFAULT_HINTS.get(code, DEFAULT_HINTS["internal"])
83
+ self.retryable = (code in RETRYABLE) if retryable is None else retryable
84
+ self.user_action_required = (user_action_required if user_action_required is not None
85
+ else USER_ACTION.get(code))
86
+ self.request_id = request_id
87
+ self.data = data or {}
88
+
89
+
90
+ def success(data, message="ok", request_id=None):
91
+ return {"ok": True, "code": 0, "message": message, "request_id": request_id, "data": data}
92
+
93
+
94
+ def failure(err: CliError):
95
+ return {
96
+ "ok": False,
97
+ "code": err.code,
98
+ "message": err.message,
99
+ "request_id": err.request_id,
100
+ "data": err.data,
101
+ "error": {
102
+ "type": err.code,
103
+ "retryable": bool(err.retryable),
104
+ "user_action_required": err.user_action_required,
105
+ "hint": err.hint,
106
+ },
107
+ }
108
+
109
+
110
+ class Output:
111
+ """Owns stdout (one envelope) and stderr (events). Redacts known secrets from everything it writes."""
112
+
113
+ def __init__(self, stdout=None, stderr=None):
114
+ self.stdout = stdout or sys.stdout
115
+ self.stderr = stderr or sys.stderr
116
+ self.pretty = False
117
+ self._secrets = []
118
+ self._emitted = False
119
+
120
+ def add_secret(self, value):
121
+ if value and value not in self._secrets:
122
+ self._secrets.append(value)
123
+
124
+ def redact(self, text):
125
+ for s in self._secrets:
126
+ if s and s in text:
127
+ text = text.replace(s, "[redacted]")
128
+ return text
129
+
130
+ def event(self, kind, **fields):
131
+ payload = {"type": kind, **fields}
132
+ line = (f"[{time.strftime('%H:%M:%S')}] shotfable_event="
133
+ + json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
134
+ self._err(line)
135
+
136
+ def note(self, text):
137
+ """Free-text stderr line for humans (warnings). Never parsed."""
138
+ self._err(f"[{time.strftime('%H:%M:%S')}] {text}")
139
+
140
+ def _err(self, line):
141
+ try:
142
+ self.stderr.write(self.redact(line) + "\n")
143
+ self.stderr.flush()
144
+ except (BrokenPipeError, ValueError):
145
+ pass
146
+
147
+ @property
148
+ def emitted(self):
149
+ return self._emitted
150
+
151
+ def envelope(self, env):
152
+ if self._emitted: # the contract: exactly one envelope per invocation
153
+ return
154
+ self._emitted = True
155
+ text = json.dumps(env, ensure_ascii=False, indent=2 if self.pretty else None)
156
+ self._out(text)
157
+
158
+ def text(self, body):
159
+ if self._emitted:
160
+ return
161
+ self._emitted = True
162
+ self._out(body.rstrip("\n"))
163
+
164
+ def _out(self, text):
165
+ try:
166
+ self.stdout.write(self.redact(text) + "\n")
167
+ self.stdout.flush()
168
+ except BrokenPipeError:
169
+ pass