vcursor-cli 1.0.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.
vcursor/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """VCursor SDK - Generate videos from text, images, and URLs using AI."""
2
+
3
+ from vcursor.client import VCursor, VCursorAsync
4
+ from vcursor.types import (
5
+ SubmitRequest,
6
+ SubmitResponse,
7
+ TaskProgress,
8
+ AgentSubmitRequest,
9
+ AgentProgress,
10
+ CategoryUsage,
11
+ ConcurrencyStatus,
12
+ )
13
+
14
+ __version__ = "1.0.0"
15
+ __all__ = [
16
+ "VCursor",
17
+ "VCursorAsync",
18
+ "SubmitRequest",
19
+ "SubmitResponse",
20
+ "TaskProgress",
21
+ "AgentSubmitRequest",
22
+ "AgentProgress",
23
+ "CategoryUsage",
24
+ "ConcurrencyStatus",
25
+ ]
vcursor/cli.py ADDED
@@ -0,0 +1,322 @@
1
+ """VCursor CLI - Python entry point.
2
+
3
+ Provides the same core commands as the Node.js CLI:
4
+ vcursor "a cat playing piano"
5
+ vcursor --agent "create a commercial"
6
+ vcursor login
7
+ vcursor whoami
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import getpass
14
+ import json
15
+ import os
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from vcursor.client import VCursor, VCursorError, ConcurrencyLimitError, _load_config, DEFAULT_SERVER
21
+
22
+ CONFIG_DIR = Path.home() / ".vcursor"
23
+ CONFIG_FILE = CONFIG_DIR / "config.json"
24
+
25
+
26
+ def _save_config(config: dict) -> None:
27
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
28
+ CONFIG_FILE.write_text(json.dumps(config, indent=2) + "\n")
29
+ CONFIG_FILE.chmod(0o600)
30
+
31
+
32
+ def _resolve_server(args: argparse.Namespace) -> str:
33
+ return getattr(args, "server", None) or os.environ.get("VCURSOR_SERVER") or _load_config().get("server") or DEFAULT_SERVER
34
+
35
+
36
+ def _resolve_api_key(args: argparse.Namespace) -> str | None:
37
+ return getattr(args, "api_key", None) or os.environ.get("VCURSOR_API_KEY") or _load_config().get("apiKey")
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Commands
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def cmd_login(args: argparse.Namespace) -> None:
45
+ server = _resolve_server(args)
46
+ api_key = getattr(args, "api_key", None)
47
+ if not api_key:
48
+ print(f"Server: {server}")
49
+ api_key = getpass.getpass("API Key: ")
50
+ if not api_key:
51
+ print("No API key provided.")
52
+ sys.exit(1)
53
+
54
+ # Validate
55
+ import httpx
56
+ try:
57
+ resp = httpx.post(
58
+ f"{server.rstrip('/')}/api/cli/validate",
59
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
60
+ json={},
61
+ timeout=15,
62
+ )
63
+ if resp.status_code != 200:
64
+ print(f"Validation failed: {resp.text}")
65
+ sys.exit(1)
66
+ except Exception as e:
67
+ print(f"Connection error: {e}")
68
+ sys.exit(1)
69
+
70
+ config = _load_config()
71
+ config["server"] = server
72
+ config["apiKey"] = api_key
73
+ _save_config(config)
74
+ print("Logged in successfully.")
75
+
76
+
77
+ def cmd_logout(_args: argparse.Namespace) -> None:
78
+ config = _load_config()
79
+ config.pop("apiKey", None)
80
+ _save_config(config)
81
+ print("Logged out.")
82
+
83
+
84
+ def cmd_whoami(args: argparse.Namespace) -> None:
85
+ server = _resolve_server(args)
86
+ api_key = _resolve_api_key(args)
87
+ if not api_key:
88
+ print("Not authenticated. Run: vcursor login")
89
+ sys.exit(1)
90
+
91
+ import httpx
92
+ try:
93
+ resp = httpx.post(
94
+ f"{server.rstrip('/')}/api/cli/validate",
95
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
96
+ json={},
97
+ timeout=15,
98
+ )
99
+ data = resp.json()
100
+ if resp.status_code == 200:
101
+ print(f"Authenticated as: {data.get('email', data.get('name', 'unknown'))}")
102
+ print(f"Server: {server}")
103
+ else:
104
+ print(f"Auth failed: {data.get('message', resp.status_code)}")
105
+ except Exception as e:
106
+ print(f"Connection error: {e}")
107
+ sys.exit(1)
108
+
109
+
110
+ def cmd_generate(args: argparse.Namespace) -> None:
111
+ api_key = _resolve_api_key(args)
112
+ if not api_key:
113
+ print("Not authenticated. Run: vcursor login")
114
+ sys.exit(1)
115
+
116
+ prompt = " ".join(args.inputs)
117
+ if not prompt:
118
+ print("No prompt provided.")
119
+ sys.exit(1)
120
+
121
+ max_conc = int(args.max_concurrency) if args.max_concurrency else None
122
+ client = VCursor(
123
+ api_key=api_key,
124
+ server=_resolve_server(args),
125
+ max_concurrency=max_conc,
126
+ )
127
+
128
+ try:
129
+ if args.agent:
130
+ _run_agent(client, prompt, args)
131
+ else:
132
+ _run_standard(client, prompt, args)
133
+ except ConcurrencyLimitError as e:
134
+ window_label = "" if e.tier == "free" else "/hr"
135
+ print(f"Rate limit reached: {e.used}/{e.rate_limit} {e.category} requests{window_label} (tier: {e.tier})")
136
+ if e.tier == "free":
137
+ print("Upgrade your plan to get more requests.")
138
+ else:
139
+ print("Wait for your quota to reset, then try again.")
140
+ sys.exit(1)
141
+ except VCursorError as e:
142
+ print(f"Error ({e.status}): {e}")
143
+ sys.exit(1)
144
+ finally:
145
+ client.close()
146
+
147
+
148
+ def _run_standard(client: VCursor, prompt: str, args: argparse.Namespace) -> None:
149
+ print(f"Submitting: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
150
+
151
+ kwargs = {}
152
+ if args.mode:
153
+ kwargs["submission_mode"] = args.mode
154
+
155
+ resp = client.submit(prompt, **kwargs)
156
+
157
+ if resp.chat_response:
158
+ print(f"\n{resp.chat_response}")
159
+ return
160
+ if resp.plan:
161
+ print(f"\nPlan:\n{resp.plan}")
162
+ return
163
+ if not resp.task_id:
164
+ print("No task ID returned.")
165
+ sys.exit(1)
166
+
167
+ print(f"Task: {resp.task_id} (mode: {resp.mode or 'auto'})")
168
+
169
+ def _on_progress(p):
170
+ d = p.data
171
+ bar_len = 30
172
+ filled = int(bar_len * d.progress / 100)
173
+ bar = "█" * filled + "░" * (bar_len - filled)
174
+ eta = f" ~{int(d.remaining_process_time)}s" if d.remaining_process_time > 0 else ""
175
+ print(f"\r [{bar}] {d.progress:.0f}% {d.status}{eta} ", end="", flush=True)
176
+
177
+ result = client.wait_for_completion(resp.task_id, on_progress=_on_progress)
178
+ print()
179
+
180
+ if result.data.status == "completed":
181
+ print("Completed!")
182
+ if result.data.products.url:
183
+ print(f" Output: {result.data.products.url}")
184
+ if result.data.products.audio_url:
185
+ print(f" Audio: {result.data.products.audio_url}")
186
+ if result.data.products.refined_prompt:
187
+ print(f" Refined: {result.data.products.refined_prompt}")
188
+ else:
189
+ print(f"Task failed: {result.data.detail or result.data.status}")
190
+ sys.exit(1)
191
+
192
+ if args.json:
193
+ print(json.dumps({"code": result.code, "data": {"status": result.data.status}}, indent=2))
194
+
195
+
196
+ def _run_agent(client: VCursor, prompt: str, args: argparse.Namespace) -> None:
197
+ print(f"Submitting agent task: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
198
+
199
+ resp = client.submit_agent(prompt)
200
+ task_id = resp.get("task_id")
201
+ if not task_id:
202
+ print("No task ID returned.")
203
+ sys.exit(1)
204
+
205
+ print(f"Agent task: {task_id}")
206
+
207
+ def _on_progress(p):
208
+ bar_len = 30
209
+ filled = int(bar_len * p.progress / 100)
210
+ bar = "█" * filled + "░" * (bar_len - filled)
211
+ stage = p.current_stage or p.status
212
+ print(f"\r [{bar}] {p.progress:.0f}% {stage} ", end="", flush=True)
213
+
214
+ result = client.wait_for_agent_completion(task_id, on_progress=_on_progress)
215
+ print()
216
+
217
+ if result.status == "completed":
218
+ print("Agent task completed!")
219
+ if result.video_url:
220
+ print(f" Video: {result.video_url}")
221
+ if result.assets_url:
222
+ print(f" Assets: {result.assets_url}")
223
+ else:
224
+ print(f"Agent task failed: {result.error_message or result.status}")
225
+ sys.exit(1)
226
+
227
+
228
+ def cmd_concurrency(args: argparse.Namespace) -> None:
229
+ """Show current concurrency status."""
230
+ api_key = _resolve_api_key(args)
231
+ if not api_key:
232
+ print("Not authenticated. Run: vcursor login")
233
+ sys.exit(1)
234
+
235
+ client = VCursor(api_key=api_key, server=_resolve_server(args))
236
+ try:
237
+ status = client.check_concurrency()
238
+ window_label = "lifetime" if status.tier == "free" else f"{status.window_hours}h window"
239
+ print(f"Tier: {status.tier} ({window_label})")
240
+ if status.summary:
241
+ s = status.summary["standard"]
242
+ a = status.summary["agent"]
243
+ print(f"Standard: {s.used}/{s.limit} used, {s.remaining} remaining")
244
+ print(f"Agent: {a.used}/{a.limit} used, {a.remaining} remaining")
245
+ if status.bypass_enabled:
246
+ print("Bypass: enabled (admin/test)")
247
+ except VCursorError as e:
248
+ print(f"Error: {e}")
249
+ sys.exit(1)
250
+ finally:
251
+ client.close()
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # Argument parser
256
+ # ---------------------------------------------------------------------------
257
+
258
+ def main() -> None:
259
+ parser = argparse.ArgumentParser(
260
+ prog="vcursor",
261
+ description="VCursor CLI - Generate videos from text, images, and URLs using AI",
262
+ )
263
+ parser.add_argument("-v", "--version", action="version", version="vcursor 1.0.0")
264
+
265
+ sub = parser.add_subparsers(dest="command")
266
+
267
+ # login
268
+ login_p = sub.add_parser("login", help="Authenticate with your VCursor API key")
269
+ login_p.add_argument("--server", help="Server URL")
270
+ login_p.add_argument("--api-key", help="API key (non-interactive)")
271
+
272
+ # logout
273
+ sub.add_parser("logout", help="Remove stored API key")
274
+
275
+ # whoami
276
+ whoami_p = sub.add_parser("whoami", help="Show current authenticated user")
277
+ whoami_p.add_argument("--server", help="Server URL")
278
+
279
+ # concurrency
280
+ conc_p = sub.add_parser("concurrency", help="Show concurrency limit status")
281
+ conc_p.add_argument("--server", help="Server URL")
282
+ conc_p.add_argument("--api-key", help="API key")
283
+
284
+ # generate (default)
285
+ gen_p = sub.add_parser("generate", help="Generate video from prompt")
286
+ gen_p.add_argument("inputs", nargs="+", help="Text prompt, file paths, or URLs")
287
+ gen_p.add_argument("--agent", action="store_true", help="Use agent mode")
288
+ gen_p.add_argument("--mode", help="Force a specific mode")
289
+ gen_p.add_argument("--max-concurrency", help="Max concurrent tasks (client-side)")
290
+ gen_p.add_argument("--server", help="Server URL")
291
+ gen_p.add_argument("--api-key", help="API key")
292
+ gen_p.add_argument("--json", action="store_true", help="Output raw JSON")
293
+
294
+ args = parser.parse_args()
295
+
296
+ # If no subcommand but there are remaining args, treat as generate
297
+ if args.command is None:
298
+ # Re-parse with generate as implicit default
299
+ remaining = sys.argv[1:]
300
+ if remaining and not remaining[0].startswith("-"):
301
+ gen_args = gen_p.parse_args(remaining)
302
+ cmd_generate(gen_args)
303
+ return
304
+ parser.print_help()
305
+ return
306
+
307
+ if args.command == "login":
308
+ cmd_login(args)
309
+ elif args.command == "logout":
310
+ cmd_logout(args)
311
+ elif args.command == "whoami":
312
+ cmd_whoami(args)
313
+ elif args.command == "concurrency":
314
+ cmd_concurrency(args)
315
+ elif args.command == "generate":
316
+ cmd_generate(args)
317
+ else:
318
+ parser.print_help()
319
+
320
+
321
+ if __name__ == "__main__":
322
+ main()
vcursor/client.py ADDED
@@ -0,0 +1,372 @@
1
+ """VCursor API client for Python - sync and async."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any, Callable
10
+
11
+ import httpx
12
+
13
+ from vcursor.types import (
14
+ AgentProgress,
15
+ AgentSubmitRequest,
16
+ ConcurrencyStatus,
17
+ SubmitRequest,
18
+ SubmitResponse,
19
+ TaskProgress,
20
+ )
21
+
22
+ DEFAULT_SERVER = "https://www.cli.vcursor.com"
23
+ CONFIG_PATH = Path.home() / ".vcursor" / "config.json"
24
+
25
+
26
+ class VCursorError(Exception):
27
+ """Error returned by the VCursor API."""
28
+
29
+ def __init__(self, message: str, status: int, data: dict[str, Any] | None = None):
30
+ super().__init__(message)
31
+ self.status = status
32
+ self.data = data
33
+
34
+
35
+ class ConcurrencyLimitError(VCursorError):
36
+ """Raised when the user has exceeded their concurrency limit."""
37
+
38
+ def __init__(self, message: str, data: dict[str, Any] | None = None):
39
+ super().__init__(message, 429, data)
40
+ self.used: int = (data or {}).get("used", 0)
41
+ self.rate_limit: int = (data or {}).get("rateLimit", 0)
42
+ self.category: str = (data or {}).get("category", "standard")
43
+ self.tier: str = (data or {}).get("tier", "free")
44
+
45
+
46
+ def _load_config() -> dict[str, Any]:
47
+ """Load config from ~/.vcursor/config.json."""
48
+ try:
49
+ return json.loads(CONFIG_PATH.read_text())
50
+ except (FileNotFoundError, json.JSONDecodeError):
51
+ return {}
52
+
53
+
54
+ def _resolve_server(server: str | None = None) -> str:
55
+ return server or os.environ.get("VCURSOR_SERVER") or _load_config().get("server") or DEFAULT_SERVER
56
+
57
+
58
+ def _resolve_api_key(api_key: str | None = None) -> str | None:
59
+ return api_key or os.environ.get("VCURSOR_API_KEY") or _load_config().get("apiKey")
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Synchronous client
64
+ # ---------------------------------------------------------------------------
65
+
66
+ class VCursor:
67
+ """Synchronous VCursor API client.
68
+
69
+ Usage::
70
+
71
+ from vcursor import VCursor
72
+
73
+ client = VCursor(api_key="vk-...")
74
+ resp = client.submit("a cat playing piano")
75
+ result = client.wait_for_completion(resp.task_id)
76
+ print(result.data.products.url)
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ api_key: str | None = None,
82
+ server: str | None = None,
83
+ max_concurrency: int | None = None,
84
+ timeout: float = 60.0,
85
+ ):
86
+ self.server = _resolve_server(server).rstrip("/")
87
+ self.api_key = _resolve_api_key(api_key)
88
+ self.max_concurrency = max_concurrency
89
+ self._client = httpx.Client(timeout=timeout)
90
+
91
+ def _headers(self) -> dict[str, str]:
92
+ h: dict[str, str] = {"Content-Type": "application/json"}
93
+ if self.api_key:
94
+ h["Authorization"] = f"Bearer {self.api_key}"
95
+ return h
96
+
97
+ def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
98
+ url = f"{self.server}{path}"
99
+ resp = self._client.request(method, url, headers=self._headers(), json=body)
100
+ data = resp.json()
101
+ if not resp.is_success:
102
+ msg = data.get("message") or data.get("error") or f"HTTP {resp.status_code}"
103
+ if resp.status_code == 429:
104
+ raise ConcurrencyLimitError(msg, data)
105
+ raise VCursorError(msg, resp.status_code, data)
106
+ return data
107
+
108
+ # --- Concurrency ---
109
+
110
+ def check_concurrency(self, category: str = "standard") -> ConcurrencyStatus:
111
+ """Check current rate limit usage and limits.
112
+
113
+ Args:
114
+ category: 'standard' or 'agent'.
115
+ """
116
+ data = self._request("POST", "/api/concurrency/check", {"category": category})
117
+ return ConcurrencyStatus.from_dict(data)
118
+
119
+ def _enforce_concurrency(self, category: str = "standard") -> None:
120
+ """Pre-flight rate limit guard. Raises ConcurrencyLimitError if over limit."""
121
+ status = self.check_concurrency(category)
122
+ effective_limit = self.max_concurrency if self.max_concurrency is not None else status.limit
123
+ if not status.bypass_enabled and status.used >= effective_limit:
124
+ window_label = "" if status.tier == "free" else "/hr"
125
+ raise ConcurrencyLimitError(
126
+ f"Rate limit reached: {status.used}/{effective_limit} {category} requests{window_label}",
127
+ {
128
+ "used": status.used,
129
+ "rateLimit": effective_limit,
130
+ "category": category,
131
+ "tier": status.tier,
132
+ },
133
+ )
134
+
135
+ # --- Standard mode ---
136
+
137
+ def submit(self, prompt: str, **kwargs: Any) -> SubmitResponse:
138
+ """Submit a standard video generation task.
139
+
140
+ Args:
141
+ prompt: Text prompt.
142
+ **kwargs: Additional fields for SubmitRequest (media_keys, reference_urls, etc.).
143
+
144
+ Returns:
145
+ SubmitResponse with task_id for polling.
146
+ """
147
+ self._enforce_concurrency("standard")
148
+ req = SubmitRequest(prompt=prompt, **kwargs)
149
+ data = self._request("POST", "/api/task/submit_anything", req.to_dict())
150
+ return SubmitResponse.from_dict(data)
151
+
152
+ def get_progress(self, task_id: str) -> TaskProgress:
153
+ """Get progress for a standard task."""
154
+ data = self._request("GET", f"/api/task/progress/{task_id}")
155
+ return TaskProgress.from_dict(data)
156
+
157
+ def cancel_task(self, task_id: str) -> dict[str, str]:
158
+ """Cancel a running task."""
159
+ return self._request("GET", f"/api/task/abort/{task_id}")
160
+
161
+ def wait_for_completion(
162
+ self,
163
+ task_id: str,
164
+ poll_interval: float = 2.0,
165
+ on_progress: Callable[[TaskProgress], None] | None = None,
166
+ ) -> TaskProgress:
167
+ """Poll until a standard task completes or fails.
168
+
169
+ Args:
170
+ task_id: Task ID to poll.
171
+ poll_interval: Seconds between polls.
172
+ on_progress: Optional callback invoked on each poll.
173
+
174
+ Returns:
175
+ Final TaskProgress.
176
+ """
177
+ while True:
178
+ progress = self.get_progress(task_id)
179
+ if on_progress:
180
+ on_progress(progress)
181
+ if progress.data.status in ("completed", "failed"):
182
+ return progress
183
+ time.sleep(poll_interval)
184
+
185
+ # --- Agent mode ---
186
+
187
+ def submit_agent(self, message: str, **kwargs: Any) -> dict[str, str]:
188
+ """Submit an agent mode task.
189
+
190
+ Args:
191
+ message: Agent prompt.
192
+ **kwargs: Additional fields for AgentSubmitRequest.
193
+
194
+ Returns:
195
+ Dict with task_id and status.
196
+ """
197
+ self._enforce_concurrency("agent")
198
+ req = AgentSubmitRequest(message=message, **kwargs)
199
+ return self._request("POST", "/api/direct/submit", req.to_dict())
200
+
201
+ def get_agent_progress(self, task_id: str) -> AgentProgress:
202
+ """Get progress for an agent task."""
203
+ data = self._request("GET", f"/api/direct/progress/{task_id}")
204
+ return AgentProgress.from_dict(data)
205
+
206
+ def cancel_agent_task(self, task_id: str) -> dict[str, str]:
207
+ """Cancel an agent task."""
208
+ return self._request("DELETE", f"/api/direct/cancel/{task_id}")
209
+
210
+ def wait_for_agent_completion(
211
+ self,
212
+ task_id: str,
213
+ poll_interval: float = 3.0,
214
+ on_progress: Callable[[AgentProgress], None] | None = None,
215
+ ) -> AgentProgress:
216
+ """Poll until an agent task completes or fails."""
217
+ while True:
218
+ progress = self.get_agent_progress(task_id)
219
+ if on_progress:
220
+ on_progress(progress)
221
+ if progress.status in ("completed", "error"):
222
+ return progress
223
+ time.sleep(poll_interval)
224
+
225
+ def close(self) -> None:
226
+ self._client.close()
227
+
228
+ def __enter__(self) -> VCursor:
229
+ return self
230
+
231
+ def __exit__(self, *args: Any) -> None:
232
+ self.close()
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Async client
237
+ # ---------------------------------------------------------------------------
238
+
239
+ class VCursorAsync:
240
+ """Async VCursor API client.
241
+
242
+ Usage::
243
+
244
+ import asyncio
245
+ from vcursor import VCursorAsync
246
+
247
+ async def main():
248
+ async with VCursorAsync(api_key="vk-...") as client:
249
+ resp = await client.submit("a cat playing piano")
250
+ result = await client.wait_for_completion(resp.task_id)
251
+ print(result.data.products.url)
252
+
253
+ asyncio.run(main())
254
+ """
255
+
256
+ def __init__(
257
+ self,
258
+ api_key: str | None = None,
259
+ server: str | None = None,
260
+ max_concurrency: int | None = None,
261
+ timeout: float = 60.0,
262
+ ):
263
+ self.server = _resolve_server(server).rstrip("/")
264
+ self.api_key = _resolve_api_key(api_key)
265
+ self.max_concurrency = max_concurrency
266
+ self._client = httpx.AsyncClient(timeout=timeout)
267
+
268
+ def _headers(self) -> dict[str, str]:
269
+ h: dict[str, str] = {"Content-Type": "application/json"}
270
+ if self.api_key:
271
+ h["Authorization"] = f"Bearer {self.api_key}"
272
+ return h
273
+
274
+ async def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
275
+ url = f"{self.server}{path}"
276
+ resp = await self._client.request(method, url, headers=self._headers(), json=body)
277
+ data = resp.json()
278
+ if not resp.is_success:
279
+ msg = data.get("message") or data.get("error") or f"HTTP {resp.status_code}"
280
+ if resp.status_code == 429:
281
+ raise ConcurrencyLimitError(msg, data)
282
+ raise VCursorError(msg, resp.status_code, data)
283
+ return data
284
+
285
+ # --- Concurrency ---
286
+
287
+ async def check_concurrency(self, category: str = "standard") -> ConcurrencyStatus:
288
+ data = await self._request("POST", "/api/concurrency/check", {"category": category})
289
+ return ConcurrencyStatus.from_dict(data)
290
+
291
+ async def _enforce_concurrency(self, category: str = "standard") -> None:
292
+ status = await self.check_concurrency(category)
293
+ effective_limit = self.max_concurrency if self.max_concurrency is not None else status.limit
294
+ if not status.bypass_enabled and status.used >= effective_limit:
295
+ window_label = "" if status.tier == "free" else "/hr"
296
+ raise ConcurrencyLimitError(
297
+ f"Rate limit reached: {status.used}/{effective_limit} {category} requests{window_label}",
298
+ {
299
+ "used": status.used,
300
+ "rateLimit": effective_limit,
301
+ "category": category,
302
+ "tier": status.tier,
303
+ },
304
+ )
305
+
306
+ # --- Standard mode ---
307
+
308
+ async def submit(self, prompt: str, **kwargs: Any) -> SubmitResponse:
309
+ await self._enforce_concurrency("standard")
310
+ req = SubmitRequest(prompt=prompt, **kwargs)
311
+ data = await self._request("POST", "/api/task/submit_anything", req.to_dict())
312
+ return SubmitResponse.from_dict(data)
313
+
314
+ async def get_progress(self, task_id: str) -> TaskProgress:
315
+ data = await self._request("GET", f"/api/task/progress/{task_id}")
316
+ return TaskProgress.from_dict(data)
317
+
318
+ async def cancel_task(self, task_id: str) -> dict[str, str]:
319
+ return await self._request("GET", f"/api/task/abort/{task_id}")
320
+
321
+ async def wait_for_completion(
322
+ self,
323
+ task_id: str,
324
+ poll_interval: float = 2.0,
325
+ on_progress: Callable[[TaskProgress], None] | None = None,
326
+ ) -> TaskProgress:
327
+ import asyncio
328
+ while True:
329
+ progress = await self.get_progress(task_id)
330
+ if on_progress:
331
+ on_progress(progress)
332
+ if progress.data.status in ("completed", "failed"):
333
+ return progress
334
+ await asyncio.sleep(poll_interval)
335
+
336
+ # --- Agent mode ---
337
+
338
+ async def submit_agent(self, message: str, **kwargs: Any) -> dict[str, str]:
339
+ await self._enforce_concurrency("agent")
340
+ req = AgentSubmitRequest(message=message, **kwargs)
341
+ return await self._request("POST", "/api/direct/submit", req.to_dict())
342
+
343
+ async def get_agent_progress(self, task_id: str) -> AgentProgress:
344
+ data = await self._request("GET", f"/api/direct/progress/{task_id}")
345
+ return AgentProgress.from_dict(data)
346
+
347
+ async def cancel_agent_task(self, task_id: str) -> dict[str, str]:
348
+ return await self._request("DELETE", f"/api/direct/cancel/{task_id}")
349
+
350
+ async def wait_for_agent_completion(
351
+ self,
352
+ task_id: str,
353
+ poll_interval: float = 3.0,
354
+ on_progress: Callable[[AgentProgress], None] | None = None,
355
+ ) -> AgentProgress:
356
+ import asyncio
357
+ while True:
358
+ progress = await self.get_agent_progress(task_id)
359
+ if on_progress:
360
+ on_progress(progress)
361
+ if progress.status in ("completed", "error"):
362
+ return progress
363
+ await asyncio.sleep(poll_interval)
364
+
365
+ async def close(self) -> None:
366
+ await self._client.aclose()
367
+
368
+ async def __aenter__(self) -> VCursorAsync:
369
+ return self
370
+
371
+ async def __aexit__(self, *args: Any) -> None:
372
+ await self.close()
vcursor/types.py ADDED
@@ -0,0 +1,222 @@
1
+ """Type definitions for the VCursor SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Optional
7
+
8
+
9
+ @dataclass
10
+ class SubmitRequest:
11
+ """Request to submit a standard video generation task."""
12
+ prompt: str
13
+ media_keys: list[str] | None = None
14
+ reference_urls: list[str] | None = None
15
+ submission_mode: str | None = None # 'plan' | 'execute' | 'ask'
16
+ plan_id: str | None = None
17
+ plan_data: dict[str, Any] | None = None
18
+
19
+ def to_dict(self) -> dict[str, Any]:
20
+ d: dict[str, Any] = {"prompt": self.prompt}
21
+ if self.media_keys:
22
+ d["media_keys"] = self.media_keys
23
+ if self.reference_urls:
24
+ d["reference_urls"] = self.reference_urls
25
+ if self.submission_mode:
26
+ d["submission_mode"] = self.submission_mode
27
+ if self.plan_id:
28
+ d["plan_id"] = self.plan_id
29
+ if self.plan_data:
30
+ d["plan_data"] = self.plan_data
31
+ return d
32
+
33
+
34
+ @dataclass
35
+ class SubmitResponse:
36
+ """Response from task submission."""
37
+ code: int
38
+ message: str
39
+ task_id: str | None = None
40
+ mode: str | None = None
41
+ chat_response: str | None = None
42
+ plan: str | None = None
43
+ plan_id: str | None = None
44
+
45
+ @classmethod
46
+ def from_dict(cls, data: dict[str, Any]) -> SubmitResponse:
47
+ return cls(
48
+ code=data.get("code", 0),
49
+ message=data.get("message", ""),
50
+ task_id=data.get("task_id"),
51
+ mode=data.get("mode"),
52
+ chat_response=data.get("chat_response"),
53
+ plan=data.get("plan"),
54
+ plan_id=data.get("plan_id"),
55
+ )
56
+
57
+
58
+ @dataclass
59
+ class TaskProducts:
60
+ """Products from a completed task."""
61
+ url: str | None = None
62
+ thumbnail_url: str | None = None
63
+ audio_url: str | None = None
64
+ upload_completed: bool = False
65
+ refined_prompt: str | None = None
66
+ aspect_ratio: str | None = None
67
+
68
+ @classmethod
69
+ def from_dict(cls, data: dict[str, Any]) -> TaskProducts:
70
+ return cls(
71
+ url=data.get("url"),
72
+ thumbnail_url=data.get("thumbnail_url"),
73
+ audio_url=data.get("audio_url"),
74
+ upload_completed=data.get("upload_completed", False),
75
+ refined_prompt=data.get("refined_prompt"),
76
+ aspect_ratio=data.get("aspect_ratio"),
77
+ )
78
+
79
+
80
+ @dataclass
81
+ class TaskProgressData:
82
+ """Inner data of task progress."""
83
+ task_id: str
84
+ status: str # 'pending' | 'processing' | 'completed' | 'failed' | 'temporal_error'
85
+ progress: float = 0.0
86
+ queuing_time: float = 0.0
87
+ remaining_process_time: float = 0.0
88
+ total_process_time: float = 0.0
89
+ products: TaskProducts = field(default_factory=TaskProducts)
90
+ detail: str | None = None
91
+
92
+ @classmethod
93
+ def from_dict(cls, data: dict[str, Any]) -> TaskProgressData:
94
+ return cls(
95
+ task_id=data.get("task_id", ""),
96
+ status=data.get("status", "pending"),
97
+ progress=data.get("progress", 0.0),
98
+ queuing_time=data.get("queuing_time", 0.0),
99
+ remaining_process_time=data.get("remaining_process_time", 0.0),
100
+ total_process_time=data.get("total_process_time", 0.0),
101
+ products=TaskProducts.from_dict(data.get("products", {})),
102
+ detail=data.get("detail"),
103
+ )
104
+
105
+
106
+ @dataclass
107
+ class TaskProgress:
108
+ """Full task progress response."""
109
+ code: int
110
+ message: str
111
+ data: TaskProgressData = field(default_factory=lambda: TaskProgressData(task_id="", status="pending"))
112
+
113
+ @classmethod
114
+ def from_dict(cls, raw: dict[str, Any]) -> TaskProgress:
115
+ return cls(
116
+ code=raw.get("code", 0),
117
+ message=raw.get("message", ""),
118
+ data=TaskProgressData.from_dict(raw.get("data", {})),
119
+ )
120
+
121
+
122
+ @dataclass
123
+ class AgentSubmitRequest:
124
+ """Request to submit an agent mode task."""
125
+ message: str
126
+ duration: str | None = None
127
+ aspect_ratio: str | None = None
128
+ model_name: str | None = None
129
+ voiceover: str | None = None
130
+ subtitles: str | None = None
131
+ bgm: str | None = None
132
+ sound_effects: str | None = None
133
+ visual_style: str | None = None
134
+
135
+ def to_dict(self) -> dict[str, Any]:
136
+ d: dict[str, Any] = {"message": self.message}
137
+ for key in ("duration", "aspect_ratio", "model_name", "voiceover",
138
+ "subtitles", "bgm", "sound_effects", "visual_style"):
139
+ val = getattr(self, key)
140
+ if val is not None:
141
+ d[key] = val
142
+ return d
143
+
144
+
145
+ @dataclass
146
+ class AgentProgress:
147
+ """Agent task progress."""
148
+ task_id: str
149
+ status: str # 'pending' | 'processing' | 'completed' | 'error'
150
+ progress: float = 0.0
151
+ current_stage: str | None = None
152
+ estimated_time_remaining: float | None = None
153
+ video_url: str | None = None
154
+ assets_url: str | None = None
155
+ error_message: str | None = None
156
+
157
+ @classmethod
158
+ def from_dict(cls, data: dict[str, Any]) -> AgentProgress:
159
+ return cls(
160
+ task_id=data.get("task_id", ""),
161
+ status=data.get("status", "pending"),
162
+ progress=data.get("progress", 0.0),
163
+ current_stage=data.get("current_stage"),
164
+ estimated_time_remaining=data.get("estimated_time_remaining"),
165
+ video_url=data.get("video_url"),
166
+ assets_url=data.get("assets_url"),
167
+ error_message=data.get("error_message"),
168
+ )
169
+
170
+
171
+ @dataclass
172
+ class CategoryUsage:
173
+ """Usage for a single task category (standard or agent)."""
174
+ used: int
175
+ limit: int | float
176
+ remaining: int | float
177
+
178
+ @classmethod
179
+ def from_dict(cls, data: dict[str, Any]) -> CategoryUsage:
180
+ return cls(
181
+ used=data.get("used", 0),
182
+ limit=data.get("limit", 0),
183
+ remaining=data.get("remaining", 0),
184
+ )
185
+
186
+
187
+ @dataclass
188
+ class ConcurrencyStatus:
189
+ """Rate limit status for the current user.
190
+
191
+ Includes separate quotas for standard and agent requests.
192
+ """
193
+ allowed: bool
194
+ limit: int | float
195
+ used: int
196
+ remaining: int | float
197
+ category: str # 'standard' | 'agent'
198
+ tier: str
199
+ bypass_enabled: bool = False
200
+ window_hours: int = 1
201
+ reset_at: str | None = None
202
+ summary: dict[str, CategoryUsage] | None = None
203
+
204
+ @classmethod
205
+ def from_dict(cls, data: dict[str, Any]) -> ConcurrencyStatus:
206
+ summary_raw = data.get("summary", {})
207
+ summary = {
208
+ "standard": CategoryUsage.from_dict(summary_raw.get("standard", {})),
209
+ "agent": CategoryUsage.from_dict(summary_raw.get("agent", {})),
210
+ } if summary_raw else None
211
+ return cls(
212
+ allowed=data.get("allowed", False),
213
+ limit=data.get("limit", 0),
214
+ used=data.get("used", 0),
215
+ remaining=data.get("remaining", 0),
216
+ category=data.get("category", "standard"),
217
+ tier=data.get("tier", "free"),
218
+ bypass_enabled=data.get("bypassEnabled", False),
219
+ window_hours=data.get("windowHours", 1),
220
+ reset_at=data.get("resetAt"),
221
+ summary=summary,
222
+ )
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: vcursor-cli
3
+ Version: 1.0.0
4
+ Summary: VCursor SDK & CLI - Generate videos from text, images, and URLs using AI
5
+ Author-email: Julius <support@vcursor.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://cli.vcursor.com
8
+ Project-URL: Repository, https://github.com/JThh/vcursor
9
+ Project-URL: Documentation, https://cli.vcursor.com/docs
10
+ Keywords: video,ai,generation,cli,vcursor,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Multimedia :: Video
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: httpx>=0.25.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7.0; extra == "dev"
27
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
28
+
29
+ # VCursor Python SDK & CLI
30
+
31
+ Generate videos from text, images, and URLs using AI.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install vcursor
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ ### As a library (embedded in code)
42
+
43
+ ```python
44
+ from vcursor import VCursor
45
+
46
+ client = VCursor(api_key="vk-...")
47
+
48
+ # Submit a video generation task
49
+ resp = client.submit("a cat playing piano in a jazz club")
50
+
51
+ # Wait for completion with progress callback
52
+ result = client.wait_for_completion(
53
+ resp.task_id,
54
+ on_progress=lambda p: print(f"{p.data.progress}%")
55
+ )
56
+
57
+ print(result.data.products.url)
58
+ ```
59
+
60
+ ### Async usage
61
+
62
+ ```python
63
+ import asyncio
64
+ from vcursor import VCursorAsync
65
+
66
+ async def main():
67
+ async with VCursorAsync(api_key="vk-...") as client:
68
+ resp = await client.submit("sunset timelapse over mountains")
69
+ result = await client.wait_for_completion(resp.task_id)
70
+ print(result.data.products.url)
71
+
72
+ asyncio.run(main())
73
+ ```
74
+
75
+ ### Concurrency limiting
76
+
77
+ The server enforces per-user rate limits based on subscription tier:
78
+
79
+ | Tier | Standard Requests | Agent Requests | Window |
80
+ |------|-------------------|----------------|-----------|
81
+ | Free | 3 | 1 | Lifetime |
82
+ | Pro | 30/hr | 5/hr | Rolling 1h|
83
+ | Plus | 120/hr | 15/hr | Rolling 1h|
84
+
85
+ ```python
86
+ # Check current rate limit status
87
+ status = client.check_concurrency("standard")
88
+ print(f"Standard: {status.summary['standard'].used}/{status.summary['standard'].limit}")
89
+ print(f"Agent: {status.summary['agent'].used}/{status.summary['agent'].limit}")
90
+
91
+ # Client-side limit (capped at server limit)
92
+ client = VCursor(api_key="vk-...", max_concurrency=10)
93
+ ```
94
+
95
+ ### As a CLI
96
+
97
+ ```bash
98
+ # Authenticate
99
+ vcursor login
100
+
101
+ # Generate a video
102
+ vcursor generate "a cat playing piano"
103
+
104
+ # Agent mode
105
+ vcursor generate --agent "create a 30s commercial"
106
+
107
+ # Check concurrency status
108
+ vcursor concurrency
109
+
110
+ # With concurrency limit
111
+ vcursor generate --max-concurrency 2 "sunset timelapse"
112
+ ```
113
+
114
+ ## API Reference
115
+
116
+ ### `VCursor` / `VCursorAsync`
117
+
118
+ | Method | Description |
119
+ |---------------------------|----------------------------------------|
120
+ | `submit(prompt, **kw)` | Submit a standard video generation task |
121
+ | `get_progress(task_id)` | Get task progress |
122
+ | `wait_for_completion()` | Poll until task completes |
123
+ | `cancel_task(task_id)` | Cancel a running task |
124
+ | `submit_agent(message)` | Submit an agent mode task |
125
+ | `get_agent_progress()` | Get agent task progress |
126
+ | `wait_for_agent_completion()` | Poll until agent task completes |
127
+ | `check_concurrency()` | Check concurrency limit status |
128
+
129
+ ## Configuration
130
+
131
+ Config is stored in `~/.vcursor/config.json` (shared with the Node.js CLI).
132
+
133
+ Environment variables:
134
+ - `VCURSOR_API_KEY` - API key
135
+ - `VCURSOR_SERVER` - Server URL
136
+
137
+ ## License
138
+
139
+ MIT
@@ -0,0 +1,9 @@
1
+ vcursor/__init__.py,sha256=tk56HRKYr179Gt5CdYS7W3ECKAnleSq845XCXFNTXJo,524
2
+ vcursor/cli.py,sha256=QM1Zt73b_ROfvYnfST-1p7lCoaTocoCb0ONkJedQPSI,10618
3
+ vcursor/client.py,sha256=fxkAD0La3__iFWgjuXZIjBwBBUqfwHt0R5uNcUK7jwE,13218
4
+ vcursor/types.py,sha256=FCKUw-EcQGryf3KUoCMODNK8pjfMWILtBqn-Kcb1zik,7082
5
+ vcursor_cli-1.0.0.dist-info/METADATA,sha256=WDoZV0X_kuGt5Q64q13rR9DSlt1YUsf0_L6CMThuqSA,4078
6
+ vcursor_cli-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
7
+ vcursor_cli-1.0.0.dist-info/entry_points.txt,sha256=ZKxaj1P8-RJJ9OrxgeNDdEaj1nzWG22bKwMr8Qar5pY,45
8
+ vcursor_cli-1.0.0.dist-info/top_level.txt,sha256=-CtKsgji6iMgfMKcDMmkMtb4msDBj0xG0i0Dmywccfs,8
9
+ vcursor_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vcursor = vcursor.cli:main
@@ -0,0 +1 @@
1
+ vcursor