sparki-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.
sparki/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Sparki — video editor CLI."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("sparki-cli")
sparki/cli.py ADDED
@@ -0,0 +1,720 @@
1
+ """CLI entry point — all sparki subcommands."""
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Annotated, Any, Optional
7
+
8
+ import httpx
9
+ import typer
10
+
11
+ from sparki.client import SparkiClient
12
+ from sparki.config import Config, DEFAULT_CONFIG_DIR
13
+ from sparki.constants import (
14
+ ALLOWED_EXTENSIONS,
15
+ MAX_FILES_PER_UPLOAD,
16
+ MAX_UPLOAD_SIZE,
17
+ EditMode,
18
+ VALID_DURATION_RANGES,
19
+ TELEGRAM_FILE_SIZE_LIMIT,
20
+ validate_style,
21
+ style_to_payload,
22
+ DEFAULT_ASSET_POLL_INTERVAL,
23
+ DEFAULT_ASSET_POLL_TIMEOUT,
24
+ DEFAULT_PROJECT_POLL_INTERVAL,
25
+ DEFAULT_PROJECT_POLL_TIMEOUT,
26
+ )
27
+ from sparki.output import log, print_error, print_success
28
+
29
+ app = typer.Typer(add_completion=False)
30
+
31
+
32
+ def get_config_dir() -> Path:
33
+ """Return the config directory path."""
34
+ return DEFAULT_CONFIG_DIR
35
+
36
+
37
+ def _load_config() -> Config:
38
+ """Load configuration from the config directory."""
39
+ return Config(config_dir=get_config_dir())
40
+
41
+
42
+ def _require_auth() -> tuple[Config, SparkiClient] | None:
43
+ """Load config and return (Config, SparkiClient), or print AUTH_FAILED and return None."""
44
+ cfg = _load_config()
45
+ if not cfg.api_key:
46
+ print_error("AUTH_FAILED")
47
+ return None
48
+ client = SparkiClient(base_url=cfg.base_url, api_key=cfg.api_key)
49
+ return cfg, client
50
+
51
+
52
+ def _validate_files(files: list[Path]) -> list[Path] | None:
53
+ """Validate file existence, format, and size. Returns list or None on error."""
54
+ if len(files) > MAX_FILES_PER_UPLOAD:
55
+ print_error("UPLOAD_FAILED", f"Too many files: max {MAX_FILES_PER_UPLOAD} allowed, got {len(files)}")
56
+ return None
57
+ for f in files:
58
+ if not f.exists():
59
+ print_error("UPLOAD_FAILED", f"File not found: {f}")
60
+ return None
61
+ ext = f.suffix.lstrip(".").lower()
62
+ if ext not in ALLOWED_EXTENSIONS:
63
+ print_error("INVALID_FILE_FORMAT")
64
+ return None
65
+ if f.stat().st_size > MAX_UPLOAD_SIZE:
66
+ print_error("FILE_TOO_LARGE")
67
+ return None
68
+ return files
69
+
70
+
71
+ def _history_file() -> Path:
72
+ """Return the path to the local project history file."""
73
+ return get_config_dir() / "sparki_history.json"
74
+
75
+
76
+ def _load_history() -> list[dict]:
77
+ """Load locally tracked project IDs."""
78
+ hf = _history_file()
79
+ if hf.exists():
80
+ return json.loads(hf.read_text())
81
+ return []
82
+
83
+
84
+ def _lookup_mode(task_id: str) -> str | None:
85
+ """Look up the edit mode for a task_id from local history."""
86
+ for entry in _load_history():
87
+ if entry.get("task_id") == task_id:
88
+ return entry.get("mode")
89
+ return None
90
+
91
+
92
+ async def _get_status_by_mode(client, task_id: str) -> dict:
93
+ """Call the correct status endpoint based on the project's mode."""
94
+ mode = _lookup_mode(task_id)
95
+ if mode == EditMode.STYLE_CLONE:
96
+ return await client.get_emulate_project_status(task_id)
97
+ return await client.get_project_status(task_id)
98
+
99
+
100
+ def _save_project(task_id: str, mode: str = "", style: str = "") -> None:
101
+ """Track a project ID locally for history lookups."""
102
+ from datetime import datetime, timezone
103
+ history = _load_history()
104
+ history.insert(0, {
105
+ "task_id": task_id,
106
+ "mode": mode,
107
+ "style": style,
108
+ "created_at": datetime.now(timezone.utc).isoformat(),
109
+ })
110
+ history = history[:100]
111
+ hf = _history_file()
112
+ hf.parent.mkdir(parents=True, exist_ok=True)
113
+ hf.write_text(json.dumps(history, indent=2))
114
+
115
+
116
+ def _extract_result_url(data: dict[str, Any]) -> str | None:
117
+ """Extract result URL from project status response data."""
118
+ materials = data.get("materials", data.get("outputResultAssets",
119
+ data.get("output_result_assets", [])))
120
+ if not materials:
121
+ return None
122
+ item = materials[0]
123
+ if isinstance(item, dict):
124
+ return (item.get("url") or item.get("download_url")
125
+ or item.get("downloadUrl"))
126
+ if isinstance(item, str):
127
+ return item
128
+ return None
129
+
130
+
131
+ def _run_async(coro_fn):
132
+ """Run an async function, catching httpx errors as NETWORK_ERROR."""
133
+ try:
134
+ asyncio.run(coro_fn())
135
+ except (httpx.HTTPError, httpx.StreamError) as e:
136
+ print_error("NETWORK_ERROR", str(e))
137
+
138
+
139
+ async def _upload_and_poll_asset(
140
+ client: SparkiClient,
141
+ file_path: Path,
142
+ label: str = "",
143
+ ) -> str | None:
144
+ """Upload a file and poll until processed. Returns object_key or None on failure."""
145
+ import time
146
+
147
+ display = label or file_path.name
148
+ log(f"Uploading {display}...")
149
+ resp = await client.upload_asset(file_path)
150
+ if resp.get("code") != 200:
151
+ print_error("UPLOAD_FAILED", resp.get("message"))
152
+ return None
153
+ object_key = resp["data"]["object_key"]
154
+
155
+ log(f"Waiting for processing: {object_key}")
156
+ start = time.monotonic()
157
+ while True:
158
+ if time.monotonic() - start >= DEFAULT_ASSET_POLL_TIMEOUT:
159
+ print_error("RENDER_TIMEOUT", "Asset processing timed out")
160
+ return None
161
+ try:
162
+ status_resp = await client.get_asset_status(object_key)
163
+ except httpx.HTTPError as exc:
164
+ log(f"Asset status check failed ({exc}), retrying...")
165
+ await asyncio.sleep(DEFAULT_ASSET_POLL_INTERVAL)
166
+ continue
167
+ asset_status = (status_resp.get("data", {}).get("status")
168
+ if status_resp.get("data") else None)
169
+ if asset_status == 1:
170
+ return object_key
171
+ if asset_status == -2:
172
+ print_error("UPLOAD_FAILED", f"Asset processing failed: {object_key}")
173
+ return None
174
+ await asyncio.sleep(DEFAULT_ASSET_POLL_INTERVAL)
175
+
176
+
177
+ @app.command()
178
+ def setup(
179
+ api_key: Annotated[str, typer.Option("--api-key", help="Your Sparki API key")],
180
+ base_url: Annotated[Optional[str], typer.Option("--base-url", help="Override the Sparki API base URL")] = None,
181
+ ) -> None:
182
+ """Save API key and validate it against the Sparki backend."""
183
+
184
+ async def _run() -> None:
185
+ cfg = _load_config()
186
+ effective_base_url = base_url or cfg.base_url
187
+ client = SparkiClient(base_url=effective_base_url, api_key=api_key)
188
+ valid = await client.validate_key()
189
+ if not valid:
190
+ print_error("AUTH_FAILED")
191
+ return
192
+ cfg.save(api_key=api_key, base_url=base_url)
193
+ log("Welcome to Sparki! Configuration saved.")
194
+ print_success({"message": "API key saved successfully",
195
+ "config_dir": str(get_config_dir())})
196
+
197
+ _run_async(_run)
198
+
199
+
200
+ @app.command()
201
+ def upload(
202
+ file: Annotated[list[Path], typer.Option("--file", help="Video file(s) to upload")],
203
+ ) -> None:
204
+ """Upload one or more video files to Sparki."""
205
+
206
+ auth = _require_auth()
207
+ if auth is None:
208
+ return
209
+ _, client = auth
210
+
211
+ validated = _validate_files(file)
212
+ if validated is None:
213
+ return
214
+
215
+ async def _run() -> None:
216
+ assets = []
217
+ for f in validated:
218
+ log(f"Uploading {f.name}...")
219
+ resp = await client.upload_asset(f)
220
+ if resp.get("code") != 200:
221
+ print_error("UPLOAD_FAILED", resp.get("message"))
222
+ return
223
+ assets.append(resp["data"])
224
+ print_success({"assets": assets})
225
+
226
+ _run_async(_run)
227
+
228
+
229
+ @app.command(name="upload-tg")
230
+ def upload_tg() -> None:
231
+ """Return the configured Telegram upload link."""
232
+ auth = _require_auth()
233
+ if auth is None:
234
+ return
235
+ cfg, _ = auth
236
+ print_success({"upload_tg": cfg.upload_tg})
237
+
238
+
239
+ @app.command()
240
+ def assets(
241
+ limit: Annotated[int, typer.Option("--limit", help="Max number of assets to return")] = 20,
242
+ ) -> None:
243
+ """List uploaded assets."""
244
+
245
+ auth = _require_auth()
246
+ if auth is None:
247
+ return
248
+ _, client = auth
249
+
250
+ async def _run() -> None:
251
+ resp = await client.list_assets(page_size=limit)
252
+ if resp.get("code") != 200:
253
+ print_error("NETWORK_ERROR", resp.get("message"))
254
+ return
255
+ print_success(resp["data"])
256
+
257
+ _run_async(_run)
258
+
259
+
260
+ @app.command()
261
+ def edit(
262
+ object_key: Annotated[list[str], typer.Option("--object-key", help="Asset object key(s) to edit")],
263
+ mode: Annotated[str, typer.Option("--mode", help="Edit mode: style-guided, prompt-driven, style-clone")],
264
+ style: Annotated[Optional[str], typer.Option("--style", help="Style for style-guided mode (e.g. vlog/daily)")] = None,
265
+ prompt: Annotated[Optional[str], typer.Option("--prompt", help="Text prompt for prompt-driven or style-clone mode")] = None,
266
+ aspect_ratio: Annotated[str, typer.Option("--aspect-ratio", help="Output aspect ratio")] = "9:16",
267
+ duration_range: Annotated[Optional[str], typer.Option("--duration-range", help="Duration range (e.g. 30s~60s)")] = None,
268
+ reference_url: Annotated[Optional[str], typer.Option("--reference-url", help="Reference video URL (TikTok, Instagram, X, Facebook)")] = None,
269
+ reference_file: Annotated[Optional[Path], typer.Option("--reference-file", help="Local reference video file")] = None,
270
+ reference_tg: Annotated[bool, typer.Option("--reference-tg", help="Get Telegram upload link for reference video")] = False,
271
+ ) -> None:
272
+ """Create a new edit project for uploaded assets."""
273
+
274
+ auth = _require_auth()
275
+ if auth is None:
276
+ return
277
+ cfg, client = auth
278
+
279
+ if duration_range and duration_range not in VALID_DURATION_RANGES:
280
+ print_error("INVALID_MODE", f"Invalid duration range: {duration_range}")
281
+ return
282
+
283
+ # Cross-mode validation
284
+ ref_count = sum([bool(reference_url), bool(reference_file), reference_tg])
285
+ if mode == EditMode.STYLE_CLONE:
286
+ if ref_count != 1:
287
+ print_error("INVALID_REFERENCE")
288
+ return
289
+ if style:
290
+ print_error("INVALID_MODE", "--style is not used with style-clone mode")
291
+ return
292
+ if duration_range:
293
+ print_error("INVALID_MODE", "--duration-range is not supported for style-clone mode")
294
+ return
295
+ else:
296
+ if ref_count > 0:
297
+ print_error("INVALID_MODE", "--reference-* options are only for style-clone mode")
298
+ return
299
+
300
+ if mode == EditMode.STYLE_GUIDED:
301
+ if not style or not validate_style(style):
302
+ print_error("INVALID_STYLE")
303
+ return
304
+ payload = style_to_payload(style)
305
+ tags = payload["tags"]
306
+ agent_type = payload["agent_type"]
307
+ user_input = prompt or payload.get("default_prompt", "")
308
+ elif mode == EditMode.PROMPT_DRIVEN:
309
+ if not prompt:
310
+ print_error("INVALID_MODE")
311
+ return
312
+ tags = []
313
+ agent_type = None
314
+ user_input = prompt
315
+ elif mode == EditMode.STYLE_CLONE:
316
+ user_input = prompt or ""
317
+
318
+ # --reference-tg: return upload link, no project created
319
+ if reference_tg:
320
+ print_success({
321
+ "action": "upload_reference_via_telegram",
322
+ "upload_tg": cfg.upload_tg,
323
+ "message": "Please ask the user to upload their reference video via the Telegram Mini App.",
324
+ })
325
+ return
326
+
327
+ async def _run_clone() -> None:
328
+ ref_key = None
329
+ ref_url = None
330
+
331
+ if reference_file:
332
+ validated = _validate_files([reference_file])
333
+ if validated is None:
334
+ return
335
+ ref_key = await _upload_and_poll_asset(
336
+ client, reference_file,
337
+ label=f"reference video: {reference_file.name}",
338
+ )
339
+ if ref_key is None:
340
+ return
341
+ elif reference_url:
342
+ ref_url = reference_url
343
+
344
+ resp = await client.create_emulate_project(
345
+ source_object_keys=object_key,
346
+ reference_object_key=ref_key,
347
+ reference_url=ref_url,
348
+ user_input=user_input,
349
+ aspect_ratio=aspect_ratio,
350
+ )
351
+ if resp.get("code") != 200:
352
+ print_error("NETWORK_ERROR", resp.get("message"))
353
+ return
354
+ data = resp["data"]
355
+ task_id = data.get("task_id", data.get("taskId", ""))
356
+ _save_project(task_id, mode=mode, style="")
357
+ print_success(data)
358
+
359
+ _run_async(_run_clone)
360
+ return
361
+ else:
362
+ print_error("INVALID_MODE")
363
+ return
364
+
365
+ # Existing style-guided / prompt-driven flow (unchanged)
366
+ async def _run() -> None:
367
+ resp = await client.create_project(
368
+ object_keys=object_key,
369
+ tags=tags,
370
+ user_input=user_input,
371
+ aspect_ratio=aspect_ratio,
372
+ agent_type=agent_type,
373
+ duration_range=duration_range,
374
+ )
375
+ if resp.get("code") != 200:
376
+ print_error("NETWORK_ERROR", resp.get("message"))
377
+ return
378
+ data = resp["data"]
379
+ task_id = data.get("task_id", data.get("taskId", ""))
380
+ _save_project(task_id, mode=mode, style=style or "")
381
+ print_success(data)
382
+
383
+ _run_async(_run)
384
+
385
+
386
+ @app.command()
387
+ def status(
388
+ task_id: Annotated[str, typer.Option("--task-id", help="Project ID to check")],
389
+ ) -> None:
390
+ """Check the status of an edit project."""
391
+
392
+ auth = _require_auth()
393
+ if auth is None:
394
+ return
395
+ _, client = auth
396
+
397
+ async def _run() -> None:
398
+ resp = await _get_status_by_mode(client, task_id)
399
+ if resp.get("code") != 200:
400
+ print_error("TASK_NOT_FOUND", resp.get("message"))
401
+ return
402
+ print_success(resp["data"])
403
+
404
+ _run_async(_run)
405
+
406
+
407
+ @app.command()
408
+ def download(
409
+ task_id: Annotated[str, typer.Option("--task-id", help="Project ID to download")],
410
+ output: Annotated[Optional[Path], typer.Option("--output", help="Output file path")] = None,
411
+ ) -> None:
412
+ """Download the result of a completed edit project."""
413
+
414
+ auth = _require_auth()
415
+ if auth is None:
416
+ return
417
+ cfg, client = auth
418
+
419
+ async def _run() -> None:
420
+ resp = await _get_status_by_mode(client, task_id)
421
+ if resp.get("code") != 200:
422
+ print_error("TASK_NOT_FOUND", resp.get("message"))
423
+ return
424
+ data = resp["data"]
425
+ task_status = data.get("status", "").upper()
426
+ if task_status != "COMPLETED":
427
+ print_error("TASK_NOT_FOUND",
428
+ f"Project {task_id} is not completed (status: {data.get('status')})")
429
+ return
430
+ result_url = _extract_result_url(data)
431
+ if not result_url:
432
+ print_error("NETWORK_ERROR", "No result URL available")
433
+ return
434
+ out_path = output or cfg.default_output_dir / f"{task_id}.mp4"
435
+ file_size = await client.download_result(result_url, out_path)
436
+ delivery_hint = ("telegram_direct" if file_size <= TELEGRAM_FILE_SIZE_LIMIT
437
+ else "link_only")
438
+ print_success({
439
+ "task_id": task_id,
440
+ "file_path": str(out_path),
441
+ "file_size": file_size,
442
+ "result_url": result_url,
443
+ "delivery_hint": delivery_hint,
444
+ })
445
+
446
+ _run_async(_run)
447
+
448
+
449
+ @app.command()
450
+ def history(
451
+ limit: Annotated[int, typer.Option("--limit", help="Number of projects to return")] = 20,
452
+ status: Annotated[Optional[str], typer.Option("--status", help="Filter by status (or 'all')")] = "all",
453
+ ) -> None:
454
+ """List recent edit projects."""
455
+
456
+ auth = _require_auth()
457
+ if auth is None:
458
+ return
459
+ _, client = auth
460
+
461
+ async def _run() -> None:
462
+ local_history = _load_history()
463
+ if not local_history:
464
+ print_success({"projects": [], "total": 0})
465
+ return
466
+ entries = local_history[:limit]
467
+ projects = []
468
+ for entry in entries:
469
+ tid = entry["task_id"]
470
+ resp = await _get_status_by_mode(client, tid)
471
+ if resp.get("code") == 200:
472
+ projects.append(resp["data"])
473
+
474
+ if status and status != "all":
475
+ projects = [p for p in projects
476
+ if p.get("status", "").upper() == status.upper()]
477
+
478
+ print_success({"projects": projects, "total": len(projects)})
479
+
480
+ _run_async(_run)
481
+
482
+
483
+ @app.command()
484
+ def run(
485
+ file: Annotated[list[Path], typer.Option("--file", help="Video file(s) to upload and edit")],
486
+ mode: Annotated[str, typer.Option("--mode", help="Edit mode: style-guided, prompt-driven, style-clone")],
487
+ style: Annotated[Optional[str], typer.Option("--style", help="Style for style-guided mode")] = None,
488
+ prompt: Annotated[Optional[str], typer.Option("--prompt", help="Text prompt for prompt-driven mode")] = None,
489
+ aspect_ratio: Annotated[str, typer.Option("--aspect-ratio", help="Output aspect ratio")] = "9:16",
490
+ duration_range: Annotated[Optional[str], typer.Option("--duration-range", help="Duration range")] = None,
491
+ output: Annotated[Optional[Path], typer.Option("--output", help="Output file path")] = None,
492
+ poll_interval: Annotated[int, typer.Option("--poll-interval", help="Seconds between project status polls")] = DEFAULT_PROJECT_POLL_INTERVAL,
493
+ timeout: Annotated[int, typer.Option("--timeout", help="Max seconds to wait for completion")] = DEFAULT_PROJECT_POLL_TIMEOUT,
494
+ reference_url: Annotated[Optional[str], typer.Option("--reference-url", help="Reference video URL (TikTok, Instagram, X, Facebook)")] = None,
495
+ reference_file: Annotated[Optional[Path], typer.Option("--reference-file", help="Local reference video file")] = None,
496
+ reference_tg: Annotated[bool, typer.Option("--reference-tg", help="Not available in run")] = False,
497
+ ) -> None:
498
+ """End-to-end workflow: upload -> edit -> poll -> download."""
499
+
500
+ auth = _require_auth()
501
+ if auth is None:
502
+ return
503
+ cfg, client = auth
504
+
505
+ # Cross-mode validation
506
+ ref_count = sum([bool(reference_url), bool(reference_file), reference_tg])
507
+ if mode == EditMode.STYLE_CLONE:
508
+ if reference_tg:
509
+ print_error("INVALID_MODE", "--reference-tg is not available in run command")
510
+ return
511
+ if ref_count != 1:
512
+ print_error("INVALID_REFERENCE")
513
+ return
514
+ if style:
515
+ print_error("INVALID_MODE", "--style is not used with style-clone mode")
516
+ return
517
+ if duration_range:
518
+ print_error("INVALID_MODE", "--duration-range is not supported for style-clone mode")
519
+ return
520
+ else:
521
+ if ref_count > 0:
522
+ print_error("INVALID_MODE", "--reference-* options are only for style-clone mode")
523
+ return
524
+
525
+ # Validate duration range
526
+ if duration_range and duration_range not in VALID_DURATION_RANGES:
527
+ print_error("INVALID_MODE", f"Invalid duration range: {duration_range}")
528
+ return
529
+
530
+ # Validate mode/style
531
+ if mode == EditMode.STYLE_GUIDED:
532
+ if not style or not validate_style(style):
533
+ print_error("INVALID_STYLE")
534
+ return
535
+ payload = style_to_payload(style)
536
+ tags = payload["tags"]
537
+ agent_type = payload["agent_type"]
538
+ user_input = prompt or payload.get("default_prompt", "")
539
+ elif mode == EditMode.PROMPT_DRIVEN:
540
+ if not prompt:
541
+ print_error("INVALID_MODE")
542
+ return
543
+ tags = []
544
+ agent_type = None
545
+ user_input = prompt
546
+ elif mode == EditMode.STYLE_CLONE:
547
+ user_input = prompt or ""
548
+ else:
549
+ print_error("INVALID_MODE")
550
+ return
551
+
552
+ validated = _validate_files(file)
553
+ if validated is None:
554
+ return
555
+
556
+ if reference_file:
557
+ ref_validated = _validate_files([reference_file])
558
+ if ref_validated is None:
559
+ return
560
+
561
+ if mode == EditMode.STYLE_CLONE:
562
+ async def _run_clone() -> None:
563
+ import time
564
+
565
+ # Step 1: Upload source files
566
+ object_keys = []
567
+ for f in validated:
568
+ key = await _upload_and_poll_asset(client, f, label=f"source video: {f.name}")
569
+ if key is None:
570
+ return
571
+ object_keys.append(key)
572
+
573
+ # Step 2: Handle reference video
574
+ ref_key = None
575
+ ref_url = None
576
+ if reference_file:
577
+ ref_key = await _upload_and_poll_asset(
578
+ client, reference_file, label=f"reference video: {reference_file.name}")
579
+ if ref_key is None:
580
+ return
581
+ elif reference_url:
582
+ ref_url = reference_url
583
+
584
+ # Step 3: Create emulate project
585
+ log("Creating style-clone project...")
586
+ proj_resp = await client.create_emulate_project(
587
+ source_object_keys=object_keys,
588
+ reference_object_key=ref_key,
589
+ reference_url=ref_url,
590
+ user_input=user_input,
591
+ aspect_ratio=aspect_ratio,
592
+ )
593
+ if proj_resp.get("code") != 200:
594
+ print_error("NETWORK_ERROR", proj_resp.get("message"))
595
+ return
596
+ data = proj_resp["data"]
597
+ task_id = data.get("task_id", data.get("taskId", ""))
598
+ _save_project(task_id, mode=mode, style="")
599
+
600
+ # Step 4: Poll for completion
601
+ proj_start = time.monotonic()
602
+ while True:
603
+ elapsed = time.monotonic() - proj_start
604
+ if elapsed >= timeout:
605
+ print_error("RENDER_TIMEOUT")
606
+ return
607
+ try:
608
+ status_resp = await client.get_emulate_project_status(task_id)
609
+ except httpx.HTTPError as exc:
610
+ log(f"Project status check failed ({exc}), retrying...")
611
+ await asyncio.sleep(poll_interval)
612
+ continue
613
+ if status_resp.get("code") != 200:
614
+ print_error("TASK_NOT_FOUND", status_resp.get("message"))
615
+ return
616
+ proj_data = status_resp["data"]
617
+ task_status = proj_data.get("status", "")
618
+ log(f"Project {task_id} status: {task_status}")
619
+ if task_status.upper() == "COMPLETED":
620
+ break
621
+ if task_status.upper() in ("FAILED", "CANCEL"):
622
+ print_error("NETWORK_ERROR", f"Project failed: {task_id}")
623
+ return
624
+ await asyncio.sleep(poll_interval)
625
+
626
+ # Step 5: Download result
627
+ result_url = _extract_result_url(proj_data)
628
+ if not result_url:
629
+ print_error("NETWORK_ERROR", "No result URL available")
630
+ return
631
+ out_path = output or cfg.default_output_dir / f"{task_id}.mp4"
632
+ file_size = await client.download_result(result_url, out_path)
633
+ delivery_hint = ("telegram_direct" if file_size <= TELEGRAM_FILE_SIZE_LIMIT
634
+ else "link_only")
635
+ print_success({
636
+ "task_id": task_id,
637
+ "status": task_status,
638
+ "file_path": str(out_path),
639
+ "file_size": file_size,
640
+ "result_url": result_url,
641
+ "delivery_hint": delivery_hint,
642
+ })
643
+
644
+ _run_async(_run_clone)
645
+ return
646
+
647
+ async def _run() -> None:
648
+ import time
649
+
650
+ # Step 1: Upload all files
651
+ object_keys = []
652
+ for f in validated:
653
+ key = await _upload_and_poll_asset(client, f)
654
+ if key is None:
655
+ return
656
+ object_keys.append(key)
657
+
658
+ # Step 2: Create project
659
+ log("Creating edit project...")
660
+ proj_resp = await client.create_project(
661
+ object_keys=object_keys,
662
+ tags=tags,
663
+ user_input=user_input,
664
+ aspect_ratio=aspect_ratio,
665
+ agent_type=agent_type,
666
+ duration_range=duration_range,
667
+ )
668
+ if proj_resp.get("code") != 200:
669
+ print_error("NETWORK_ERROR", proj_resp.get("message"))
670
+ return
671
+ data = proj_resp["data"]
672
+ task_id = data.get("task_id", data.get("taskId", ""))
673
+ _save_project(task_id, mode=mode, style=style or "")
674
+
675
+ # Step 3: Poll for project completion
676
+ proj_start = time.monotonic()
677
+ while True:
678
+ elapsed = time.monotonic() - proj_start
679
+ if elapsed >= timeout:
680
+ print_error("RENDER_TIMEOUT")
681
+ return
682
+ try:
683
+ status_resp = await client.get_project_status(task_id)
684
+ except httpx.HTTPError as exc:
685
+ log(f"Project status check failed ({exc}), retrying...")
686
+ await asyncio.sleep(poll_interval)
687
+ continue
688
+ if status_resp.get("code") != 200:
689
+ print_error("TASK_NOT_FOUND", status_resp.get("message"))
690
+ return
691
+ proj_data = status_resp["data"]
692
+ task_status = proj_data.get("status", "")
693
+ log(f"Project {task_id} status: {task_status}")
694
+ if task_status.upper() in ("COMPLETED",):
695
+ break
696
+ if task_status.upper() in ("FAILED", "CANCEL"):
697
+ print_error("NETWORK_ERROR", f"Project failed: {task_id}")
698
+ return
699
+ await asyncio.sleep(poll_interval)
700
+
701
+ # Step 4: Download result
702
+ result_url = _extract_result_url(proj_data)
703
+ if not result_url:
704
+ print_error("NETWORK_ERROR", "No result URL available")
705
+ return
706
+ out_path = output or cfg.default_output_dir / f"{task_id}.mp4"
707
+ file_size = await client.download_result(result_url, out_path)
708
+ delivery_hint = ("telegram_direct" if file_size <= TELEGRAM_FILE_SIZE_LIMIT
709
+ else "link_only")
710
+
711
+ print_success({
712
+ "task_id": task_id,
713
+ "status": task_status,
714
+ "file_path": str(out_path),
715
+ "file_size": file_size,
716
+ "result_url": result_url,
717
+ "delivery_hint": delivery_hint,
718
+ })
719
+
720
+ _run_async(_run)
sparki/client.py ADDED
@@ -0,0 +1,135 @@
1
+ """HTTP API client wrapping all Sparki C-end API endpoints."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+
9
+ class SparkiClient:
10
+ def __init__(self, base_url: str, api_key: str):
11
+ self.base_url = base_url.rstrip("/")
12
+ self.api_key = api_key
13
+ self._headers = {"X-API-Key": api_key}
14
+
15
+ def _url(self, path: str) -> str:
16
+ return f"{self.base_url}{path}"
17
+
18
+ async def validate_key(self) -> bool:
19
+ async with httpx.AsyncClient() as c:
20
+ resp = await c.get(self._url("/api/v1/account/info"),
21
+ headers=self._headers)
22
+ return resp.status_code == 200
23
+
24
+ async def upload_asset(self, file_path: Path) -> dict[str, Any]:
25
+ async with httpx.AsyncClient(timeout=300) as c:
26
+ with open(file_path, "rb") as f:
27
+ files = {"file": (file_path.name, f,
28
+ f"video/{file_path.suffix.lstrip('.').lower()}")}
29
+ resp = await c.post(self._url("/api/v1/assets/upload"),
30
+ headers=self._headers, files=files)
31
+ return resp.json()
32
+
33
+ async def list_assets(self, page: int = 1, page_size: int = 20) -> dict[str, Any]:
34
+ params: dict[str, Any] = {"page": page, "page_size": page_size}
35
+ async with httpx.AsyncClient() as c:
36
+ resp = await c.get(self._url("/api/v1/assets/user_assets"),
37
+ headers=self._headers, params=params)
38
+ return resp.json()
39
+
40
+ async def get_asset_status(self, object_key: str) -> dict[str, Any]:
41
+ page = 1
42
+ page_size = 50
43
+ while True:
44
+ result = await self.list_assets(page=page, page_size=page_size)
45
+ data = result.get("data", {})
46
+ items = data.get("assets", data.get("items", []))
47
+ for item in items:
48
+ if item.get("object_key") == object_key or item.get("objectKey") == object_key:
49
+ return {"code": result.get("code"), "data": item}
50
+ if len(items) < page_size:
51
+ break
52
+ page += 1
53
+ return {"code": result.get("code"), "data": None}
54
+
55
+ async def create_project(
56
+ self,
57
+ object_keys: list[str],
58
+ tags: list[str],
59
+ user_input: str = "",
60
+ aspect_ratio: str = "9:16",
61
+ agent_type: str | None = None,
62
+ duration_range: str | None = None,
63
+ ) -> dict[str, Any]:
64
+ resources = [{"idx": i, "s3_object_key": key}
65
+ for i, key in enumerate(object_keys)]
66
+ body: dict[str, Any] = {
67
+ "resources": resources,
68
+ "user_input": user_input,
69
+ "generation_preferences": {"aspect_ratio": aspect_ratio},
70
+ "send_after_create": True,
71
+ }
72
+ if tags:
73
+ body["tags"] = tags
74
+ if agent_type:
75
+ body["agent_type"] = agent_type
76
+ if duration_range:
77
+ body["generation_preferences"]["duration_range"] = duration_range
78
+ async with httpx.AsyncClient() as c:
79
+ resp = await c.post(self._url("/api/v1/projects/"),
80
+ headers=self._headers, json=body)
81
+ return resp.json()
82
+
83
+ async def get_project_status(self, task_id: str) -> dict[str, Any]:
84
+ async with httpx.AsyncClient() as c:
85
+ resp = await c.get(self._url(f"/api/v1/projects/task/{task_id}"),
86
+ headers=self._headers)
87
+ return resp.json()
88
+
89
+ async def create_emulate_project(
90
+ self,
91
+ source_object_keys: list[str],
92
+ reference_object_key: str | None = None,
93
+ reference_url: str | None = None,
94
+ user_input: str = "",
95
+ aspect_ratio: str = "9:16",
96
+ ) -> dict[str, Any]:
97
+ ref: dict[str, Any] = {"idx": 0, "media_type": "video"}
98
+ if reference_object_key:
99
+ ref["s3_object_key"] = reference_object_key
100
+ elif reference_url:
101
+ ref["video_url"] = reference_url
102
+
103
+ sources = [{"idx": i, "s3_object_key": key, "media_type": "video"}
104
+ for i, key in enumerate(source_object_keys)]
105
+
106
+ body: dict[str, Any] = {
107
+ "reference_resource": [ref],
108
+ "resources": sources,
109
+ "user_input": user_input,
110
+ "send_after_create": True,
111
+ "generation_preferences": {"aspect_ratio": aspect_ratio},
112
+ }
113
+ async with httpx.AsyncClient() as c:
114
+ resp = await c.post(self._url("/api/v1/emulate/projects/"),
115
+ headers=self._headers, json=body)
116
+ return resp.json()
117
+
118
+ async def get_emulate_project_status(self, task_id: str) -> dict[str, Any]:
119
+ async with httpx.AsyncClient() as c:
120
+ resp = await c.get(
121
+ self._url(f"/api/v1/emulate/projects/task/{task_id}"),
122
+ headers=self._headers)
123
+ return resp.json()
124
+
125
+ async def download_result(self, url: str, output_path: Path) -> int:
126
+ async with httpx.AsyncClient(timeout=600, follow_redirects=True) as c:
127
+ async with c.stream("GET", url) as resp:
128
+ resp.raise_for_status()
129
+ output_path.parent.mkdir(parents=True, exist_ok=True)
130
+ total = 0
131
+ with open(output_path, "wb") as f:
132
+ async for chunk in resp.aiter_bytes(chunk_size=1024 * 1024):
133
+ f.write(chunk)
134
+ total += len(chunk)
135
+ return total
sparki/config.py ADDED
@@ -0,0 +1,55 @@
1
+ """Configuration management — API key, base URL, output directory."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from sparki.constants import DEFAULT_BASE_URL, DEFAULT_UPLOAD_TG_LINK
8
+
9
+ DEFAULT_CONFIG_DIR = Path.home() / ".openclaw" / "config"
10
+
11
+
12
+ class Config:
13
+ def __init__(self, config_dir: Path | None = None):
14
+ self.config_dir = config_dir or DEFAULT_CONFIG_DIR
15
+ self.config_file = self.config_dir / "sparki.json"
16
+ self._data: dict = {}
17
+ if self.config_file.exists():
18
+ self._data = json.loads(self.config_file.read_text())
19
+
20
+ @property
21
+ def api_key(self) -> str | None:
22
+ env_key = os.environ.get("SPARKI_API_KEY")
23
+ if env_key:
24
+ return env_key
25
+ return self._data.get("api_key")
26
+
27
+ @property
28
+ def base_url(self) -> str:
29
+ return self._data.get("base_url", DEFAULT_BASE_URL)
30
+
31
+ @property
32
+ def default_output_dir(self) -> Path:
33
+ configured = self._data.get("default_output_dir")
34
+ if configured:
35
+ return Path(configured).expanduser()
36
+ return Path.home() / ".openclaw" / "workspace" / "sparki" / "videos"
37
+
38
+ @property
39
+ def upload_tg(self) -> str:
40
+ env = os.environ.get("SPARKI_UPLOAD_TG_LINK")
41
+ if env:
42
+ return env
43
+ return self._data.get("upload_tg", DEFAULT_UPLOAD_TG_LINK)
44
+
45
+ def save(self, api_key: str | None = None, base_url: str | None = None, default_output_dir: str | None = None) -> None:
46
+ self.config_dir.mkdir(parents=True, exist_ok=True)
47
+ if api_key is not None:
48
+ self._data["api_key"] = api_key
49
+ if base_url is not None:
50
+ self._data["base_url"] = base_url
51
+ elif "base_url" not in self._data:
52
+ self._data["base_url"] = DEFAULT_BASE_URL
53
+ if default_output_dir is not None:
54
+ self._data["default_output_dir"] = default_output_dir
55
+ self.config_file.write_text(json.dumps(self._data, indent=2))
sparki/constants.py ADDED
@@ -0,0 +1,144 @@
1
+ """Edit modes, style catalog, error codes, and limit constants."""
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class EditMode(StrEnum):
7
+ STYLE_GUIDED = "style-guided"
8
+ PROMPT_DRIVEN = "prompt-driven"
9
+ STYLE_CLONE = "style-clone"
10
+
11
+
12
+ STYLE_CATALOG: dict[str, dict[str | None, dict]] = {
13
+ "vlog": {
14
+ "energetic-sports": {"tags": ["19"], "agent_type": None},
15
+ "funny-commentary": {"tags": ["20"], "agent_type": None},
16
+ "daily": {"tags": ["21"], "agent_type": "vlog"},
17
+ "upbeat-energy": {"tags": ["22"], "agent_type": None},
18
+ "chill-vibe": {"tags": ["23"], "agent_type": "vlog"},
19
+ },
20
+ "montage": {
21
+ "highlight-reel": {"tags": ["28"], "agent_type": None},
22
+ "hype-beatsync": {"tags": ["29"], "agent_type": None},
23
+ "creative-splitscreen": {"tags": ["30"], "agent_type": None},
24
+ "meme-moments": {"tags": ["31"], "agent_type": None},
25
+ },
26
+ "commentary": {
27
+ "tiktok-trending-recap": {"tags": ["24"], "agent_type": None},
28
+ "funny-commentary": {"tags": ["25"], "agent_type": None},
29
+ "master-storyteller": {"tags": ["26"], "agent_type": None},
30
+ "first-person-narration": {"tags": ["27"], "agent_type": None},
31
+ },
32
+ "talking-head": {
33
+ "tutorial": {"tags": ["32"], "agent_type": None},
34
+ "podcast-interview": {"tags": ["33"], "agent_type": None},
35
+ "product-review": {"tags": ["34"], "agent_type": None},
36
+ "reaction-commentary": {"tags": ["35"], "agent_type": None},
37
+ },
38
+ "long-to-short": {
39
+ None: {"tags": [], "agent_type": None,
40
+ "default_prompt": "Find hooks, highlights, and turn the video into viral shorts"},
41
+ },
42
+ "ai-caption": {
43
+ None: {"tags": [], "agent_type": None,
44
+ "default_prompt": "Add captions to the video"},
45
+ },
46
+ "video-resizer": {
47
+ None: {"tags": [], "agent_type": None,
48
+ "default_prompt": "Reframe the video for the target aspect ratio"},
49
+ },
50
+ }
51
+
52
+ VALID_DURATION_RANGES = {"<30s", "30s~60s", "60s~90s", ">90s", "custom"}
53
+
54
+ TELEGRAM_FILE_SIZE_LIMIT = 100 * 1024 * 1024
55
+ MAX_UPLOAD_SIZE = 3 * 1024 * 1024 * 1024
56
+ ALLOWED_EXTENSIONS = {"mp4", "mov"}
57
+ MAX_FILES_PER_UPLOAD = 10
58
+ DEFAULT_ASSET_POLL_INTERVAL = 10
59
+ DEFAULT_ASSET_POLL_TIMEOUT = 1200
60
+ DEFAULT_PROJECT_POLL_INTERVAL = 30
61
+ DEFAULT_PROJECT_POLL_TIMEOUT = 3600
62
+ DEFAULT_BASE_URL = "https://agent-api.sparki.io"
63
+ DEFAULT_UPLOAD_TG_LINK = "https://t.me/Sparki_AI_bot/upload"
64
+
65
+
66
+ def validate_style(style: str) -> bool:
67
+ if not style:
68
+ return False
69
+ if "/" in style:
70
+ category, sub = style.split("/", 1)
71
+ return category in STYLE_CATALOG and sub in STYLE_CATALOG[category]
72
+ else:
73
+ return style in STYLE_CATALOG and None in STYLE_CATALOG[style]
74
+
75
+
76
+ def style_to_payload(style: str) -> dict:
77
+ if "/" in style:
78
+ category, sub = style.split("/", 1)
79
+ return STYLE_CATALOG[category][sub]
80
+ else:
81
+ return STYLE_CATALOG[style][None]
82
+
83
+
84
+ def all_styles_list() -> list[str]:
85
+ styles = []
86
+ for category, subs in STYLE_CATALOG.items():
87
+ for sub in subs:
88
+ if sub is None:
89
+ styles.append(category)
90
+ else:
91
+ styles.append(f"{category}/{sub}")
92
+ return styles
93
+
94
+
95
+ ERROR_CODES: dict[str, dict[str, str]] = {
96
+ "AUTH_FAILED": {
97
+ "message": "Invalid or missing API key",
98
+ "action": "Run `sparki setup --api-key <key>` or get a key from @sparki_bot on Telegram",
99
+ },
100
+ "QUOTA_EXCEEDED": {
101
+ "message": "API quota exhausted",
102
+ "action": "Visit https://sparki.io/pricing to upgrade",
103
+ },
104
+ "FILE_TOO_LARGE": {
105
+ "message": "File exceeds 3GB limit",
106
+ "action": "Compress or trim the video before uploading",
107
+ },
108
+ "CONCURRENT_LIMIT": {
109
+ "message": "Too many projects running",
110
+ "action": "Wait for a running project to complete",
111
+ },
112
+ "INVALID_FILE_FORMAT": {
113
+ "message": "Unsupported file format",
114
+ "action": "Only mp4 and mov files are supported",
115
+ },
116
+ "INVALID_STYLE": {
117
+ "message": "Unknown style",
118
+ "action": "See available styles with `sparki edit --help`",
119
+ },
120
+ "INVALID_MODE": {
121
+ "message": "Unknown edit mode",
122
+ "action": "Choose from: style-guided, prompt-driven, style-clone",
123
+ },
124
+ "INVALID_REFERENCE": {
125
+ "message": "Reference video required for style-clone mode",
126
+ "action": "Provide --reference-url, --reference-file, or --reference-tg",
127
+ },
128
+ "UPLOAD_FAILED": {
129
+ "message": "Upload failed",
130
+ "action": "Check your network connection and try again",
131
+ },
132
+ "RENDER_TIMEOUT": {
133
+ "message": "Video processing timed out",
134
+ "action": "Try a shorter clip or increase --timeout",
135
+ },
136
+ "TASK_NOT_FOUND": {
137
+ "message": "Task not found",
138
+ "action": "Run `sparki history` to see your recent tasks",
139
+ },
140
+ "NETWORK_ERROR": {
141
+ "message": "Cannot reach Sparki servers",
142
+ "action": "Check your internet connection and try again",
143
+ },
144
+ }
sparki/models.py ADDED
@@ -0,0 +1,59 @@
1
+ """Pydantic models for API responses and CLI output."""
2
+
3
+ from pydantic import BaseModel, computed_field
4
+
5
+ from sparki.constants import TELEGRAM_FILE_SIZE_LIMIT
6
+
7
+
8
+ class AssetInfo(BaseModel):
9
+ object_key: str
10
+ file_name: str
11
+ status: int
12
+ file_size: int
13
+ is_duplicate: bool = False
14
+ duration: float | None = None
15
+ resolution: str | None = None
16
+
17
+
18
+ class UploadResponse(BaseModel):
19
+ object_key: str
20
+ file_name: str
21
+ file_size: int
22
+ status: int
23
+ is_duplicate: bool = False
24
+
25
+
26
+ class ProjectInfo(BaseModel):
27
+ task_id: str
28
+ status: str
29
+ created_at: str | None = None
30
+ result_url: str | None = None
31
+ thumbnail_url: str | None = None
32
+ duration: float | None = None
33
+ resolution: str | None = None
34
+ filesize: int | None = None
35
+
36
+
37
+ class DownloadResult(BaseModel):
38
+ task_id: str
39
+ file_path: str
40
+ file_size: int
41
+ result_url: str
42
+
43
+ @computed_field
44
+ @property
45
+ def delivery_hint(self) -> str:
46
+ return "telegram_direct" if self.file_size <= TELEGRAM_FILE_SIZE_LIMIT else "link_only"
47
+
48
+
49
+ class RunResult(BaseModel):
50
+ task_id: str
51
+ status: str
52
+ file_path: str
53
+ file_size: int
54
+ result_url: str
55
+
56
+ @computed_field
57
+ @property
58
+ def delivery_hint(self) -> str:
59
+ return "telegram_direct" if self.file_size <= TELEGRAM_FILE_SIZE_LIMIT else "link_only"
sparki/output.py ADDED
@@ -0,0 +1,42 @@
1
+ """Unified JSON output formatter for all CLI commands."""
2
+
3
+ import json
4
+ import sys
5
+ from typing import Any
6
+
7
+ from sparki.constants import ERROR_CODES
8
+
9
+
10
+ def success(data: dict[str, Any]) -> str:
11
+ return json.dumps({"ok": True, "data": data, "error": None}, ensure_ascii=False)
12
+
13
+
14
+ def format_error(code: str, message: str | None = None) -> dict[str, str]:
15
+ defaults = ERROR_CODES.get(code, {
16
+ "message": "An unexpected error occurred",
17
+ "action": "Please try again or contact support",
18
+ })
19
+ return {
20
+ "code": code,
21
+ "message": message or defaults["message"],
22
+ "action": defaults["action"],
23
+ }
24
+
25
+
26
+ def error(code: str, message: str | None = None) -> str:
27
+ return json.dumps(
28
+ {"ok": False, "data": None, "error": format_error(code, message)},
29
+ ensure_ascii=False,
30
+ )
31
+
32
+
33
+ def print_success(data: dict[str, Any]) -> None:
34
+ print(success(data))
35
+
36
+
37
+ def print_error(code: str, message: str | None = None) -> None:
38
+ print(error(code, message))
39
+
40
+
41
+ def log(msg: str) -> None:
42
+ print(msg, file=sys.stderr)
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: sparki-cli
3
+ Version: 1.0.0
4
+ Summary: Sparki video editor CLI
5
+ Author: Sparki AI
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Environment :: Console
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: httpx>=0.27.0
15
+ Requires-Dist: pydantic>=2.0.0
16
+ Requires-Dist: typer>=0.9.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Sparki
20
+
21
+ AI-powered video editing from the command line.
22
+
23
+ ## Installation
24
+
25
+ ```
26
+ pip install sparki-cli
27
+ ```
28
+
29
+ Or with [uv](https://docs.astral.sh/uv/):
30
+
31
+ ```
32
+ uv tool install sparki-cli
33
+ ```
34
+
35
+ ## Quick Start
36
+
37
+ ```bash
38
+ # Save your API key
39
+ sparki setup --api-key YOUR_KEY
40
+
41
+ # Upload, edit, and download in one step
42
+ sparki run --file video.mp4 --mode style-guided --style vlog/daily
43
+
44
+ # Or step by step
45
+ sparki upload --file video.mp4
46
+ sparki edit --object-key KEY --mode prompt-driven --prompt "Make a highlight reel"
47
+ sparki status --task-id TASK_ID
48
+ sparki download --task-id TASK_ID
49
+ ```
50
+
51
+ ## Commands
52
+
53
+ | Command | Description |
54
+ |---------|-------------|
55
+ | `setup` | Save and validate your API key |
56
+ | `upload` | Upload video files |
57
+ | `assets` | List uploaded assets |
58
+ | `edit` | Create an edit project |
59
+ | `status` | Check project status |
60
+ | `download` | Download completed result |
61
+ | `run` | End-to-end: upload → edit → download |
62
+ | `history` | List recent projects |
63
+
64
+ ## Edit Modes
65
+
66
+ - **style-guided** — Choose from preset styles (e.g. `vlog/daily`, `montage/highlight-reel`)
67
+ - **prompt-driven** — Describe what you want in natural language
68
+ - **style-clone** — Clone the style of a reference video
69
+
70
+ ## Links
71
+
72
+ - Website: https://sparki.io
73
+ - Telegram: https://t.me/Sparki_AI_bot
@@ -0,0 +1,12 @@
1
+ sparki/__init__.py,sha256=ma_MWG7nIP75DiC8DoydG1CQPoQdRofU7uRTDyLAzMA,112
2
+ sparki/cli.py,sha256=mq3-6xwwbTEq4sK9D2rZlEHzacw22LfaQqnHvQHWO_0,25968
3
+ sparki/client.py,sha256=nZnrFd16ZWNxsRIc9p9k7kEREdUfmS7vt-RRmDdwCAk,5493
4
+ sparki/config.py,sha256=-uDZUFA3W9uCijBsRGcVybZt2-suaFIS4BdoU1TQu88,1957
5
+ sparki/constants.py,sha256=kNViOaP4VqwSEoFWh7QFBosjVuqyiVPqM7XUdKl2LRU,5119
6
+ sparki/models.py,sha256=zY3yak-Gl3y25-80Ah1LFWcJRgruu_6IiUo93qdT1LE,1337
7
+ sparki/output.py,sha256=lGTom-RhDr2PV-UAE_q0anf9nCYv4p5B-FiAIF-QUTQ,1063
8
+ sparki_cli-1.0.0.dist-info/METADATA,sha256=KMyRYLRSqiUN0R-5228SCR_TzZZrBNA3xbx3Q9BBK4M,1820
9
+ sparki_cli-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ sparki_cli-1.0.0.dist-info/entry_points.txt,sha256=cB61gHZ_-lbfAbOvk_g9Xx_DA4ikReQmDD_n4kX8sEI,42
11
+ sparki_cli-1.0.0.dist-info/licenses/LICENSE,sha256=VBTuXUpmn-mx-nb_NI2o2SsxjA27gDC90UdPIvKWYvM,1066
12
+ sparki_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sparki = sparki.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sparki AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.