@leejungkiin/awkit 1.3.8 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/bin/awk.js +630 -52
  2. package/bin/claude-generators.js +122 -0
  3. package/core/AGENTS.md +54 -0
  4. package/core/CLAUDE.md +155 -0
  5. package/core/GEMINI.md +44 -9
  6. package/core/GEMINI.md.bak +126 -199
  7. package/package.json +1 -1
  8. package/skills/ai-sprite-maker/SKILL.md +81 -0
  9. package/skills/ai-sprite-maker/scripts/animate_sprite.py +102 -0
  10. package/skills/ai-sprite-maker/scripts/process_sprites.py +140 -0
  11. package/skills/awf-session-restore/SKILL.md +12 -2
  12. package/skills/brainstorm-agent/SKILL.md +11 -8
  13. package/skills/code-review/SKILL.md +21 -33
  14. package/skills/gitnexus/gitnexus-cli/SKILL.md +82 -0
  15. package/skills/gitnexus/gitnexus-debugging/SKILL.md +89 -0
  16. package/skills/gitnexus/gitnexus-exploring/SKILL.md +78 -0
  17. package/skills/gitnexus/gitnexus-guide/SKILL.md +64 -0
  18. package/skills/gitnexus/gitnexus-impact-analysis/SKILL.md +97 -0
  19. package/skills/gitnexus/gitnexus-refactoring/SKILL.md +121 -0
  20. package/skills/lucylab-tts/SKILL.md +64 -0
  21. package/skills/lucylab-tts/resources/voices_library.json +908 -0
  22. package/skills/lucylab-tts/scripts/.env +1 -0
  23. package/skills/lucylab-tts/scripts/lucylab_tts.py +506 -0
  24. package/skills/nm-memory-sync/SKILL.md +14 -1
  25. package/skills/orchestrator/SKILL.md +5 -38
  26. package/skills/ship-to-code/SKILL.md +115 -0
  27. package/skills/short-maker/SKILL.md +150 -0
  28. package/skills/short-maker/_backup/storyboard.html +106 -0
  29. package/skills/short-maker/_backup/video_mixer.py +296 -0
  30. package/skills/short-maker/outputs/fitbite-promo/background.jpg +0 -0
  31. package/skills/short-maker/outputs/fitbite-promo/final/promo-final.mp4 +0 -0
  32. package/skills/short-maker/outputs/fitbite-promo/script.md +19 -0
  33. package/skills/short-maker/outputs/fitbite-promo/segments/scene-01.mp4 +0 -0
  34. package/skills/short-maker/outputs/fitbite-promo/segments/scene-02.mp4 +0 -0
  35. package/skills/short-maker/outputs/fitbite-promo/segments/scene-03.mp4 +0 -0
  36. package/skills/short-maker/outputs/fitbite-promo/segments/scene-04.mp4 +0 -0
  37. package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-01.png +0 -0
  38. package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-02.png +0 -0
  39. package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-03.png +0 -0
  40. package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-04.png +0 -0
  41. package/skills/short-maker/outputs/fitbite-promo/storyboard.html +133 -0
  42. package/skills/short-maker/outputs/fitbite-promo/storyboard.json +38 -0
  43. package/skills/short-maker/outputs/fitbite-promo/temp/merged_chroma.mp4 +0 -0
  44. package/skills/short-maker/outputs/fitbite-promo/temp/merged_crossfaded.mp4 +0 -0
  45. package/skills/short-maker/outputs/fitbite-promo/temp/ready_00.mp4 +0 -0
  46. package/skills/short-maker/outputs/fitbite-promo/temp/ready_01.mp4 +0 -0
  47. package/skills/short-maker/outputs/fitbite-promo/temp/ready_02.mp4 +0 -0
  48. package/skills/short-maker/outputs/fitbite-promo/temp/ready_03.mp4 +0 -0
  49. package/skills/short-maker/outputs/fitbite-promo/tts/manifest.json +31 -0
  50. package/skills/short-maker/outputs/fitbite-promo/tts/scene-01.wav +0 -0
  51. package/skills/short-maker/outputs/fitbite-promo/tts/scene-02.wav +0 -0
  52. package/skills/short-maker/outputs/fitbite-promo/tts/scene-03.wav +0 -0
  53. package/skills/short-maker/outputs/fitbite-promo/tts/scene-04.wav +0 -0
  54. package/skills/short-maker/outputs/fitbite-promo/tts_script.txt +11 -0
  55. package/skills/short-maker/scripts/google-flow-cli/.project-identity +41 -0
  56. package/skills/short-maker/scripts/google-flow-cli/.trae/rules/project_rules.md +52 -0
  57. package/skills/short-maker/scripts/google-flow-cli/CODEBASE.md +67 -0
  58. package/skills/short-maker/scripts/google-flow-cli/GoogleFlowCli.code-workspace +29 -0
  59. package/skills/short-maker/scripts/google-flow-cli/README.md +168 -0
  60. package/skills/short-maker/scripts/google-flow-cli/docs/specs/PROJECT.md +12 -0
  61. package/skills/short-maker/scripts/google-flow-cli/docs/specs/REQUIREMENTS.md +22 -0
  62. package/skills/short-maker/scripts/google-flow-cli/docs/specs/ROADMAP.md +16 -0
  63. package/skills/short-maker/scripts/google-flow-cli/docs/specs/TECH-SPEC.md +13 -0
  64. package/skills/short-maker/scripts/google-flow-cli/gflow/__init__.py +3 -0
  65. package/skills/short-maker/scripts/google-flow-cli/gflow/api/__init__.py +19 -0
  66. package/skills/short-maker/scripts/google-flow-cli/gflow/api/client.py +1921 -0
  67. package/skills/short-maker/scripts/google-flow-cli/gflow/api/models.py +64 -0
  68. package/skills/short-maker/scripts/google-flow-cli/gflow/api/rpc_ids.py +98 -0
  69. package/skills/short-maker/scripts/google-flow-cli/gflow/auth/__init__.py +15 -0
  70. package/skills/short-maker/scripts/google-flow-cli/gflow/auth/browser_auth.py +692 -0
  71. package/skills/short-maker/scripts/google-flow-cli/gflow/auth/humanizer.py +417 -0
  72. package/skills/short-maker/scripts/google-flow-cli/gflow/auth/proxy_ext.py +120 -0
  73. package/skills/short-maker/scripts/google-flow-cli/gflow/auth/recaptcha.py +482 -0
  74. package/skills/short-maker/scripts/google-flow-cli/gflow/batchexecute/__init__.py +5 -0
  75. package/skills/short-maker/scripts/google-flow-cli/gflow/batchexecute/client.py +414 -0
  76. package/skills/short-maker/scripts/google-flow-cli/gflow/cli/__init__.py +1 -0
  77. package/skills/short-maker/scripts/google-flow-cli/gflow/cli/main.py +1075 -0
  78. package/skills/short-maker/scripts/google-flow-cli/pyproject.toml +36 -0
  79. package/skills/short-maker/scripts/google-flow-cli/script.txt +22 -0
  80. package/skills/short-maker/scripts/google-flow-cli/tests/__init__.py +0 -0
  81. package/skills/short-maker/scripts/google-flow-cli/tests/test_batchexecute.py +113 -0
  82. package/skills/short-maker/scripts/google-flow-cli/tests/test_client.py +190 -0
  83. package/skills/short-maker/templates/aida_script.md +40 -0
  84. package/skills/short-maker/templates/mimic_analyzer.md +29 -0
  85. package/skills/single-flow-task-execution/SKILL.md +412 -0
  86. package/skills/single-flow-task-execution/code-quality-reviewer-prompt.md +20 -0
  87. package/skills/single-flow-task-execution/implementer-prompt.md +78 -0
  88. package/skills/single-flow-task-execution/spec-reviewer-prompt.md +61 -0
  89. package/skills/skill-creator/SKILL.md +44 -0
  90. package/skills/spm-build-analysis/SKILL.md +92 -0
  91. package/skills/spm-build-analysis/references/build-optimization-sources.md +155 -0
  92. package/skills/spm-build-analysis/references/recommendation-format.md +85 -0
  93. package/skills/spm-build-analysis/references/spm-analysis-checks.md +105 -0
  94. package/skills/spm-build-analysis/scripts/check_spm_pins.py +118 -0
  95. package/skills/symphony-enforcer/SKILL.md +83 -97
  96. package/skills/symphony-orchestrator/SKILL.md +1 -1
  97. package/skills/trello-sync/SKILL.md +52 -45
  98. package/skills/verification-gate/SKILL.md +13 -2
  99. package/skills/xcode-build-benchmark/SKILL.md +88 -0
  100. package/skills/xcode-build-benchmark/references/benchmark-artifacts.md +94 -0
  101. package/skills/xcode-build-benchmark/references/benchmarking-workflow.md +67 -0
  102. package/skills/xcode-build-benchmark/schemas/build-benchmark.schema.json +230 -0
  103. package/skills/xcode-build-benchmark/scripts/benchmark_builds.py +308 -0
  104. package/skills/xcode-build-fixer/SKILL.md +218 -0
  105. package/skills/xcode-build-fixer/references/build-settings-best-practices.md +216 -0
  106. package/skills/xcode-build-fixer/references/fix-patterns.md +290 -0
  107. package/skills/xcode-build-fixer/references/recommendation-format.md +85 -0
  108. package/skills/xcode-build-fixer/scripts/benchmark_builds.py +308 -0
  109. package/skills/xcode-build-orchestrator/SKILL.md +156 -0
  110. package/skills/xcode-build-orchestrator/references/benchmark-artifacts.md +94 -0
  111. package/skills/xcode-build-orchestrator/references/build-settings-best-practices.md +216 -0
  112. package/skills/xcode-build-orchestrator/references/orchestration-report-template.md +143 -0
  113. package/skills/xcode-build-orchestrator/references/recommendation-format.md +85 -0
  114. package/skills/xcode-build-orchestrator/scripts/benchmark_builds.py +308 -0
  115. package/skills/xcode-build-orchestrator/scripts/diagnose_compilation.py +273 -0
  116. package/skills/xcode-build-orchestrator/scripts/generate_optimization_report.py +533 -0
  117. package/skills/xcode-compilation-analyzer/SKILL.md +89 -0
  118. package/skills/xcode-compilation-analyzer/references/build-optimization-sources.md +155 -0
  119. package/skills/xcode-compilation-analyzer/references/code-compilation-checks.md +106 -0
  120. package/skills/xcode-compilation-analyzer/references/recommendation-format.md +85 -0
  121. package/skills/xcode-compilation-analyzer/scripts/diagnose_compilation.py +273 -0
  122. package/skills/xcode-project-analyzer/SKILL.md +76 -0
  123. package/skills/xcode-project-analyzer/references/build-optimization-sources.md +155 -0
  124. package/skills/xcode-project-analyzer/references/build-settings-best-practices.md +216 -0
  125. package/skills/xcode-project-analyzer/references/project-audit-checks.md +101 -0
  126. package/skills/xcode-project-analyzer/references/recommendation-format.md +85 -0
  127. package/templates/CODEBASE.md +26 -42
  128. package/templates/configs/trello-config.json +2 -2
  129. package/templates/workflow_dual_mode_template.md +5 -5
  130. package/workflows/_uncategorized/conductor-codex.md +125 -0
  131. package/workflows/_uncategorized/conductor.md +97 -0
  132. package/workflows/_uncategorized/ship-to-code.md +85 -0
  133. package/workflows/_uncategorized/trello-sync.md +52 -0
  134. package/workflows/context/codebase-sync.md +10 -87
  135. package/workflows/quality/visual-debug.md +66 -12
@@ -0,0 +1,1075 @@
1
+ """
2
+ gflow CLI — Command-line interface to Google Flow.
3
+
4
+ Uses Google's Imagen 4 and Veo 3.1 via the reverse-engineered
5
+ aisandbox-pa.googleapis.com endpoints with cookie-based auth.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import requests
17
+
18
+ import click
19
+ from rich.console import Console
20
+ from rich.table import Table
21
+
22
+ from gflow import __version__
23
+ from gflow.auth import BrowserAuth, load_env, save_env, refresh_access_token, kill_auth_browser
24
+ from gflow.auth.browser_auth import clear_env, AuthError
25
+ from gflow.api.client import FlowClient, FlowAPIError
26
+ from gflow.api.models import (
27
+ ExtendVideoRequest,
28
+ GenerateImageRequest,
29
+ GenerateVideoRequest,
30
+ )
31
+
32
+ console = Console()
33
+ logger = logging.getLogger("gflow")
34
+
35
+
36
+ def _get_client(debug: bool = False) -> FlowClient:
37
+ """Create an authenticated FlowClient, auto-launching auth if needed."""
38
+ auth = load_env()
39
+ if not auth or not auth.is_valid:
40
+ console.print("[yellow]Not authenticated — launching browser login...[/yellow]")
41
+ browser_auth = BrowserAuth(debug=debug)
42
+ try:
43
+ auth = browser_auth.get_auth(interactive=True)
44
+ save_env(auth)
45
+ except AuthError as e:
46
+ console.print(f"[red]Authentication failed:[/red] {e}")
47
+ sys.exit(1)
48
+
49
+ return FlowClient(
50
+ cookies=auth.cookies,
51
+ debug=debug,
52
+ )
53
+
54
+
55
+ # =============================================================
56
+ # Root CLI group
57
+ # =============================================================
58
+
59
+ @click.group()
60
+ @click.version_option(version=__version__, prog_name="gflow")
61
+ @click.option("--debug", is_flag=True, help="Enable debug output")
62
+ @click.pass_context
63
+ def cli(ctx: click.Context, debug: bool):
64
+ """gflow - CLI for Google Flow (AI image & video generation)."""
65
+ ctx.ensure_object(dict)
66
+ ctx.obj["debug"] = debug
67
+
68
+ if debug:
69
+ logging.basicConfig(level=logging.DEBUG, format="%(name)s: %(message)s")
70
+ else:
71
+ logging.basicConfig(level=logging.WARNING)
72
+
73
+
74
+ # =============================================================
75
+ # Auth
76
+ # =============================================================
77
+
78
+ @cli.command()
79
+ @click.option("--profile", default=None, help="Chrome profile directory")
80
+ @click.option("--clear", "do_clear", is_flag=True, help="Clear saved credentials")
81
+ @click.option("--status", "show_status", is_flag=True, help="Show auth status")
82
+ @click.pass_context
83
+ def auth(ctx: click.Context, profile, do_clear, show_status):
84
+ """Authenticate with Google Flow via browser login."""
85
+ debug = ctx.obj["debug"]
86
+
87
+ if do_clear:
88
+ clear_env()
89
+ console.print("[green]Credentials cleared.[/green]")
90
+ return
91
+
92
+ if show_status:
93
+ data = load_env()
94
+ if data and data.is_valid:
95
+ try:
96
+ session = refresh_access_token(data.cookies, debug=debug)
97
+ user = session.get("user", {})
98
+ console.print(f"[green]Authenticated[/green] as {user.get('name', '?')} ({user.get('email', '?')})")
99
+ console.print(f"Token: {session['access_token'][:25]}...")
100
+ console.print(f"Expires: {session.get('expires', '?')}")
101
+ except AuthError as e:
102
+ console.print(f"[yellow]Cookies saved but session expired:[/yellow] {e}")
103
+ console.print("Run 'gflow auth --clear && gflow auth' to re-authenticate.")
104
+ else:
105
+ console.print("[yellow]Not authenticated.[/yellow] Run 'gflow auth' to log in.")
106
+ return
107
+
108
+ browser_auth = BrowserAuth(debug=debug)
109
+ try:
110
+ data = browser_auth.get_auth(profile=profile, interactive=True)
111
+ save_env(data)
112
+ console.print("[green]Authentication successful![/green]")
113
+ console.print("Credentials saved to ~/.gflow/env")
114
+ except AuthError as e:
115
+ console.print(f"[red]Authentication failed:[/red] {e}")
116
+ sys.exit(1)
117
+
118
+
119
+ # =============================================================
120
+ # Close background Chrome
121
+ # =============================================================
122
+
123
+ @cli.command("close")
124
+ @click.pass_context
125
+ def close_browser(ctx):
126
+ """Close the Chrome browser that was kept alive for reCAPTCHA.
127
+
128
+ \b
129
+ After 'gflow auth', Chrome stays open so that image/video generation
130
+ can obtain reCAPTCHA tokens. Run this command when you're done.
131
+ """
132
+ kill_auth_browser()
133
+ console.print("[green]Chrome session closed.[/green]")
134
+
135
+
136
+ # =============================================================
137
+ # Image generation
138
+ # =============================================================
139
+
140
+ @cli.command("generate-image")
141
+ @click.argument("prompt")
142
+ @click.option("--aspect-ratio", default="landscape",
143
+ type=click.Choice(["square", "portrait", "landscape", "4:3", "1:1", "16:9", "9:16"]),
144
+ help="Image aspect ratio")
145
+ @click.option("--seed", default=None, type=int, help="Random seed for reproducibility")
146
+ @click.option("--num", default=1, type=click.IntRange(1, 8), help="Number of images (1-8)")
147
+ @click.option("-o", "--output", default=None, help="Output file path (auto-named if omitted)")
148
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
149
+ @click.pass_context
150
+ def generate_image(ctx, prompt, aspect_ratio, seed, num, output, as_json):
151
+ """Generate images from a text prompt using Imagen 4.
152
+
153
+ \b
154
+ Examples:
155
+ gflow generate-image "a cat astronaut floating in space"
156
+ gflow generate-image "sunset over mountains" --aspect-ratio landscape --num 4
157
+ gflow generate-image "logo design" --aspect-ratio square -o logo.png
158
+ """
159
+ client = _get_client(ctx.obj["debug"])
160
+
161
+ req = GenerateImageRequest(
162
+ prompt=prompt,
163
+ aspect_ratio=aspect_ratio,
164
+ seed=seed,
165
+ num_images=num,
166
+ )
167
+
168
+ try:
169
+ console.print("[dim]Connecting to auth browser for reCAPTCHA...[/dim]")
170
+ assets = client.generate_image(req)
171
+ except FlowAPIError as e:
172
+ console.print(f"[red]Error:[/red] {e}")
173
+ client.close()
174
+ sys.exit(1)
175
+
176
+ if not assets:
177
+ console.print("[yellow]No images generated.[/yellow]")
178
+ client.close()
179
+ sys.exit(1)
180
+
181
+ # Save images to disk
182
+ saved_files = []
183
+ for i, asset in enumerate(assets):
184
+ if output and len(assets) == 1:
185
+ filepath = output
186
+ elif output:
187
+ p = Path(output)
188
+ filepath = str(p.parent / f"{p.stem}-{i}{p.suffix}")
189
+ else:
190
+ filepath = f"gflow-image-{i}.png"
191
+
192
+ try:
193
+ path = client.save_image(asset, filepath)
194
+ saved_files.append(str(path))
195
+ console.print(f"[green]Saved:[/green] {path}")
196
+ except FlowAPIError as e:
197
+ console.print(f"[yellow]Could not save image {i}:[/yellow] {e}")
198
+
199
+ client.close()
200
+
201
+ if as_json:
202
+ result = []
203
+ for asset in assets:
204
+ d = asset.model_dump()
205
+ if "encodedImage" in d.get("raw", {}):
206
+ enc = d["raw"]["encodedImage"]
207
+ d["raw"]["encodedImage"] = f"<{len(enc)} chars base64>"
208
+ result.append(d)
209
+ click.echo(json.dumps({"images": result, "saved_files": saved_files}, indent=2))
210
+ else:
211
+ console.print(f"\n[bold]Generated {len(assets)} image(s)[/bold] for: {prompt}")
212
+
213
+
214
+ # =============================================================
215
+ # Video generation
216
+ # =============================================================
217
+
218
+ @cli.command("generate-video")
219
+ @click.argument("prompt")
220
+ @click.option("--aspect-ratio", default="landscape",
221
+ type=click.Choice(["square", "portrait", "landscape", "16:9", "9:16", "1:1"]))
222
+ @click.option("--seed", default=None, type=int)
223
+ @click.option("--wait/--no-wait", default=True, help="Wait for video to finish rendering")
224
+ @click.option("--timeout", default=300, type=int, help="Max wait seconds (default: 300)")
225
+ @click.option("-o", "--output", default=None, help="Output file path")
226
+ @click.option("-i", "--image", default=None, help="Starting frame image path (image-to-video)")
227
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
228
+ @click.pass_context
229
+ def generate_video(ctx, prompt, aspect_ratio, seed, wait, timeout, output, image, as_json):
230
+ """Generate a video from a text prompt (or image + prompt) using Veo 3.1.
231
+
232
+ \b
233
+ Video generation is async — it takes 1-3 minutes.
234
+ By default, gflow will poll until the video is ready.
235
+
236
+ \b
237
+ Examples:
238
+ gflow generate-video "a timelapse of a flower blooming"
239
+ gflow generate-video "drone flyover of a city" --aspect-ratio landscape
240
+ gflow generate-video "ocean waves" -o waves.mp4
241
+ gflow generate-video "cat walking" --no-wait # just submit, don't wait
242
+ gflow generate-video "person dancing" -i frame.png # image-to-video
243
+ """
244
+ client = _get_client(ctx.obj["debug"])
245
+
246
+ # Validate image path if provided
247
+ if image:
248
+ img_path = Path(image)
249
+ if not img_path.exists():
250
+ console.print(f"[red]Error: Image file not found:[/red] {image}")
251
+ sys.exit(1)
252
+ image = str(img_path.resolve()) # Use absolute path
253
+ console.print(f"[cyan]🖼️ Image-to-video mode:[/cyan] {img_path.name}")
254
+
255
+ req = GenerateVideoRequest(
256
+ prompt=prompt,
257
+ aspect_ratio=aspect_ratio,
258
+ seed=seed,
259
+ start_image=image,
260
+ )
261
+
262
+ mode_label = "image-to-video (I2V)" if image else "text-to-video (T2V)"
263
+ try:
264
+ with console.status(f"[bold green]Submitting {mode_label} generation (Veo 3.1)..."):
265
+ assets = client.generate_video(req)
266
+ except FlowAPIError as e:
267
+ console.print(f"[red]Error:[/red] {e}")
268
+ sys.exit(1)
269
+
270
+ if not assets:
271
+ console.print("[yellow]No video operation returned.[/yellow]")
272
+ sys.exit(1)
273
+
274
+ # Get operation names for polling
275
+ op_names = [a.id for a in assets if a.id]
276
+ console.print(f"[bold]Video submitted.[/bold] Operations: {len(op_names)}")
277
+
278
+ if wait and op_names:
279
+ try:
280
+ with console.status("[bold green]Rendering video (this takes 1-3 minutes)..."):
281
+ final_assets = client.wait_for_video(op_names, timeout=timeout)
282
+ except FlowAPIError as e:
283
+ console.print(f"[red]Error:[/red] {e}")
284
+ sys.exit(1)
285
+
286
+ for i, asset in enumerate(final_assets):
287
+ out = output or f"gflow-video-{i}.mp4"
288
+ try:
289
+ path = client.save_video(asset, out)
290
+ console.print(f"[green]Saved:[/green] {path}")
291
+ except Exception as e:
292
+ console.print(f"[yellow]Download failed:[/yellow] {e}")
293
+ if asset.url:
294
+ console.print(f"Video URL: {asset.url}")
295
+ sys.exit(1)
296
+
297
+ if as_json:
298
+ click.echo(json.dumps([a.model_dump() for a in final_assets], indent=2, default=str))
299
+ else:
300
+ console.print(f"\n[bold]Video rendered[/bold] for: {prompt}")
301
+ else:
302
+ for op in op_names:
303
+ console.print(f" Operation: {op}")
304
+ console.print("Run with --wait or use 'gflow wait <op-name>' to check status.")
305
+
306
+ if as_json:
307
+ click.echo(json.dumps([a.model_dump() for a in assets], indent=2, default=str))
308
+
309
+
310
+ # =============================================================
311
+ # Video extend
312
+ # =============================================================
313
+
314
+ @cli.command("extend-video")
315
+ @click.argument("media_id")
316
+ @click.argument("prompt")
317
+ @click.option("--aspect-ratio", default="landscape",
318
+ type=click.Choice(["square", "portrait", "landscape", "16:9", "9:16", "1:1"]))
319
+ @click.option("--seed", default=None, type=int)
320
+ @click.option("--wait/--no-wait", default=True, help="Wait for video to finish rendering")
321
+ @click.option("--timeout", default=300, type=int, help="Max wait seconds (default: 300)")
322
+ @click.option("-o", "--output", default=None, help="Output file path")
323
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
324
+ @click.pass_context
325
+ def extend_video(ctx, media_id, prompt, aspect_ratio, seed, wait, timeout, output, as_json):
326
+ """Extend an existing video with a continuation prompt.
327
+
328
+ \b
329
+ MEDIA_ID is the media name/ID of the video to extend.
330
+ You can get it from generate-video --json output.
331
+
332
+ \b
333
+ Examples:
334
+ gflow extend-video abc123-def456 "the cat jumps onto a couch"
335
+ gflow extend-video abc123 "camera pans left" -o extended.mp4
336
+ """
337
+ client = _get_client(ctx.obj["debug"])
338
+
339
+ req = ExtendVideoRequest(
340
+ prompt=prompt,
341
+ media_id=media_id,
342
+ aspect_ratio=aspect_ratio,
343
+ seed=seed,
344
+ )
345
+
346
+ try:
347
+ with console.status("[bold green]Submitting video extend (Veo 3.1)..."):
348
+ assets = client.extend_video(req)
349
+ except FlowAPIError as e:
350
+ console.print(f"[red]Error:[/red] {e}")
351
+ sys.exit(1)
352
+
353
+ if not assets:
354
+ console.print("[yellow]No video operation returned.[/yellow]")
355
+ sys.exit(1)
356
+
357
+ op_names = [a.id for a in assets if a.id]
358
+ console.print(f"[bold]Extend submitted.[/bold] Operations: {len(op_names)}")
359
+
360
+ if wait and op_names:
361
+ try:
362
+ with console.status("[bold green]Rendering extended video (this takes 1-3 minutes)..."):
363
+ final_assets = client.wait_for_video(op_names, timeout=timeout)
364
+ except FlowAPIError as e:
365
+ console.print(f"[red]Error:[/red] {e}")
366
+ sys.exit(1)
367
+
368
+ for i, asset in enumerate(final_assets):
369
+ out = output or f"gflow-extend-{i}.mp4"
370
+ try:
371
+ path = client.save_video(asset, out)
372
+ console.print(f"[green]Saved:[/green] {path}")
373
+ except Exception as e:
374
+ console.print(f"[yellow]Download failed:[/yellow] {e}")
375
+ if asset.url:
376
+ console.print(f"Video URL: {asset.url}")
377
+
378
+ if as_json:
379
+ click.echo(json.dumps([a.model_dump() for a in final_assets], indent=2, default=str))
380
+ else:
381
+ console.print("\n[bold]Extended video rendered.[/bold]")
382
+ # Print the new media ID for chaining
383
+ for asset in final_assets:
384
+ console.print(f" New media ID: {asset.id}")
385
+ else:
386
+ for op in op_names:
387
+ console.print(f" Operation: {op}")
388
+
389
+ if as_json:
390
+ click.echo(json.dumps([a.model_dump() for a in assets], indent=2, default=str))
391
+
392
+
393
+ # =============================================================
394
+ # Long video (auto-extend loop)
395
+ # =============================================================
396
+
397
+ @cli.command("long-video")
398
+ @click.argument("prompt")
399
+ @click.option("--extend-prompt", "-e", multiple=True,
400
+ help="Prompt(s) for each extension. Can specify multiple times. If fewer than --extensions, last prompt is reused.")
401
+ @click.option("--extensions", "-n", default=4, type=click.IntRange(1, 50),
402
+ help="Number of times to extend (default: 4, each ~8s = ~40s total)")
403
+ @click.option("--aspect-ratio", default="landscape",
404
+ type=click.Choice(["square", "portrait", "landscape", "16:9", "9:16", "1:1"]))
405
+ @click.option("--seed", default=None, type=int)
406
+ @click.option("--timeout", default=300, type=int, help="Max wait per segment (default: 300)")
407
+ @click.option("-o", "--output-dir", default=".", help="Output directory for segments")
408
+ @click.option("--prefix", default="gflow-long", help="Filename prefix for segments")
409
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
410
+ @click.pass_context
411
+ def long_video(ctx, prompt, extend_prompt, extensions, aspect_ratio, seed,
412
+ timeout, output_dir, prefix, as_json):
413
+ """Generate a long video by auto-extending multiple times.
414
+
415
+ \b
416
+ First generates a base video from PROMPT, then extends it
417
+ N times (default 4). Each Veo 3.1 segment is ~8 seconds,
418
+ so 4 extensions = ~40 seconds total.
419
+
420
+ \b
421
+ Use -e to specify different prompts for each extension,
422
+ or leave blank to auto-continue with the original prompt.
423
+
424
+ \b
425
+ Examples:
426
+ gflow long-video "a cat exploring a garden" -n 6
427
+ gflow long-video "drone flying over city" -e "camera dives down" -e "flies through streets"
428
+ gflow long-video "ocean waves" -n 10 -o ./my-video --prefix ocean
429
+ """
430
+ import time as _time
431
+
432
+ client = _get_client(ctx.obj["debug"])
433
+ out_dir = Path(output_dir)
434
+ out_dir.mkdir(parents=True, exist_ok=True)
435
+
436
+ all_assets = []
437
+ segment_paths = []
438
+
439
+ # ---- Prompt sanitization for policy violations ----
440
+ _POLICY_KEYWORDS = re.compile(
441
+ r'\b(explosion|exploding|blood|bloody|gore|gory|weapon|weapons|gun|guns|'
442
+ r'rifle|pistol|bullet|bullets|missile|bomb|bombing|grenade|'
443
+ r'kill|killing|murder|dead body|corpse|decapitat|dismember|'
444
+ r'nude|naked|sexual|erotic|'
445
+ r'drug|cocaine|heroin|meth|'
446
+ r'torture|torment|mutilat|'
447
+ r'suicide|self.harm|'
448
+ r'terrorist|terrorism|extremist|'
449
+ r'child|children|minor|kid|infant|baby|toddler)\b',
450
+ re.IGNORECASE
451
+ )
452
+ _POLICY_REPLACEMENTS = {
453
+ 'explosion': 'bright energy burst', 'exploding': 'rapidly expanding',
454
+ 'blood': 'red fluid', 'bloody': 'dramatic', 'gore': 'intensity', 'gory': 'intense',
455
+ 'weapon': 'tool', 'weapons': 'tools', 'gun': 'device', 'guns': 'devices',
456
+ 'rifle': 'long device', 'pistol': 'small device',
457
+ 'bullet': 'projectile', 'bullets': 'projectiles',
458
+ 'missile': 'fast-moving object', 'bomb': 'impact', 'bombing': 'impact event',
459
+ 'grenade': 'small object',
460
+ 'kill': 'overcome', 'killing': 'overcoming', 'murder': 'conflict',
461
+ 'fire': 'warm glow', 'flame': 'warm light', 'flames': 'warm lights', 'burning': 'glowing',
462
+ 'nuclear': 'powerful', 'war': 'conflict', 'warfare': 'conflict',
463
+ 'death': 'end', 'dead': 'still', 'die': 'fade', 'dying': 'fading',
464
+ }
465
+ _GENERIC_FALLBACKS = [
466
+ "Cinematic slow aerial shot of a serene mountain landscape at golden hour, soft clouds, documentary style, 4K",
467
+ "Smooth dolly shot through a lush forest with sunlight filtering through trees, peaceful atmosphere, cinematic",
468
+ "Aerial tracking shot over a calm ocean at sunset, gentle waves reflecting golden light, documentary cinematic",
469
+ "Slow pan across a stunning nebula in deep space, vibrant colors, stars twinkling, cinematic documentary",
470
+ "Cinematic wide shot of rolling hills with wildflowers swaying in the breeze, warm golden sunlight, peaceful",
471
+ "Smooth tracking shot through an ancient library with dramatic lighting, dust particles in light beams, cinematic",
472
+ ]
473
+
474
+ def _is_policy_violation(error_str):
475
+ """Check if an error is a content policy/safety violation."""
476
+ lower = error_str.lower()
477
+ return any(kw in lower for kw in [
478
+ 'safety', 'policy', 'blocked', 'violat', 'prohibited',
479
+ 'harmful', 'inappropriate', 'content filter', 'moderation',
480
+ 'responsible ai', 'rai_blocked', 'rai policy',
481
+ 'could not generate', 'generation was blocked',
482
+ ])
483
+
484
+ def _sanitize_prompt(orig_prompt, attempt):
485
+ """Clean a prompt that triggered a policy violation."""
486
+ if attempt >= 2:
487
+ # Third attempt — use a generic safe fallback
488
+ import hashlib
489
+ idx = int(hashlib.md5(orig_prompt.encode()).hexdigest(), 16) % len(_GENERIC_FALLBACKS)
490
+ return _GENERIC_FALLBACKS[idx]
491
+ # Second attempt — strip/replace flagged words and add safe prefix
492
+ cleaned = orig_prompt
493
+ for word, replacement in _POLICY_REPLACEMENTS.items():
494
+ cleaned = re.sub(r'\b' + re.escape(word) + r'\b', replacement, cleaned, flags=re.IGNORECASE)
495
+ # Remove any remaining flagged words not in replacements dict
496
+ cleaned = _POLICY_KEYWORDS.sub('', cleaned)
497
+ # Clean up double spaces
498
+ cleaned = re.sub(r'\s{2,}', ' ', cleaned).strip()
499
+ # Add documentary/cinematic prefix for safety
500
+ if not cleaned.lower().startswith(('cinematic', 'documentary', 'artistic', 'aerial')):
501
+ cleaned = f"Cinematic documentary shot of {cleaned}"
502
+ return cleaned
503
+
504
+ # ---- Step 1: Generate base video (with retry) ----
505
+ console.print(f"\n[bold]Step 1/{extensions + 1}:[/bold] Generating base video...")
506
+
507
+ base_asset = None
508
+ _base_last_error = None
509
+ for base_attempt in range(3):
510
+ base_prompt = prompt
511
+
512
+ if base_attempt > 0:
513
+ wait = 10 * base_attempt
514
+ console.print(f" [yellow]Base video retry {base_attempt}/2 after {wait}s...[/yellow]")
515
+ _time.sleep(wait)
516
+
517
+ # If previous failure looked like a policy violation, sanitize
518
+ if _base_last_error is not None and _base_last_error.is_policy:
519
+ base_prompt = _sanitize_prompt(prompt, base_attempt)
520
+ console.print(f" [cyan]Sanitized prompt:[/cyan] {base_prompt[:80]}...")
521
+
522
+ class _BaseError:
523
+ is_policy = False
524
+ msg = ""
525
+ _base_last_error = _BaseError()
526
+
527
+ try:
528
+ req = GenerateVideoRequest(
529
+ prompt=base_prompt,
530
+ aspect_ratio=aspect_ratio,
531
+ seed=seed,
532
+ )
533
+
534
+ try:
535
+ with console.status("[bold green]Submitting base video..."):
536
+ assets = client.generate_video(req)
537
+ except FlowAPIError as e:
538
+ err_str = str(e)
539
+ _base_last_error.msg = err_str
540
+ _base_last_error.is_policy = _is_policy_violation(err_str)
541
+ console.print(f" [red]Error submitting base video:[/red] {err_str[:120]}")
542
+ continue
543
+
544
+ if not assets:
545
+ _base_last_error.msg = "No operation returned"
546
+ console.print(" [yellow]No video operation returned.[/yellow]")
547
+ continue
548
+
549
+ op_names = [a.id for a in assets if a.id]
550
+ try:
551
+ with console.status("[bold green]Rendering base video (1-3 min)..."):
552
+ final_assets = client.wait_for_video(op_names, timeout=timeout)
553
+ except FlowAPIError as e:
554
+ err_str = str(e)
555
+ _base_last_error.msg = err_str
556
+ _base_last_error.is_policy = _is_policy_violation(err_str)
557
+ console.print(f" [red]Error rendering base video:[/red] {err_str[:120]}")
558
+ continue
559
+
560
+ if not final_assets:
561
+ _base_last_error.msg = "No assets returned"
562
+ _base_last_error.is_policy = True
563
+ console.print(" [yellow]Base video returned no assets (possibly blocked).[/yellow]")
564
+ continue
565
+
566
+ base_asset = final_assets[0]
567
+ all_assets.append(base_asset)
568
+ break # success
569
+
570
+ except (requests.exceptions.ConnectionError, ConnectionResetError, OSError) as e:
571
+ _base_last_error.msg = str(e)
572
+ console.print(f" [red]Connection error on base video:[/red] {str(e)[:120]}")
573
+ continue
574
+
575
+ except Exception as e:
576
+ _base_last_error.msg = str(e)
577
+ _base_last_error.is_policy = _is_policy_violation(str(e))
578
+ console.print(f" [red]Unexpected error on base video:[/red] {str(e)[:120]}")
579
+ continue
580
+
581
+ if base_asset is None:
582
+ console.print("[red bold]Base video failed after 3 attempts. Cannot continue.[/red bold]")
583
+ sys.exit(1)
584
+
585
+ # Save base segment
586
+ seg_path = out_dir / f"{prefix}-seg0.mp4"
587
+ try:
588
+ path = client.save_video(base_asset, seg_path)
589
+ segment_paths.append(path)
590
+ console.print(f" [green]Segment 0 saved:[/green] {path}")
591
+ except Exception as e:
592
+ console.print(f" [yellow]Download failed:[/yellow] {e}")
593
+
594
+ # Use primaryMediaId from workflow (this is what extend needs to find the source video)
595
+ current_media_id = client.get_primary_media_id() or client.get_media_name_for_op(base_asset.id)
596
+ workflow_id = client._workflow_id or ""
597
+ console.print(f" Media ID (primaryMedia): {current_media_id}")
598
+
599
+ # Finalize the workflow (PATCH displayName) — the Flow UI does this
600
+ # after every generation and it may be needed for extend to find the media
601
+ if workflow_id:
602
+ prompt_short = prompt[:30].replace('"', '')
603
+ client.update_workflow(workflow_id, display_name=prompt_short)
604
+
605
+ # ---- Step 2+: Extend loop ----
606
+ MAX_SEGMENT_RETRIES = 3
607
+ for i in range(extensions):
608
+ step = i + 2
609
+ console.print(f"\n[bold]Step {step}/{extensions + 1}:[/bold] Extending video (segment {i + 1})...")
610
+
611
+ # Pick the extend prompt
612
+ if extend_prompt and i < len(extend_prompt):
613
+ ext_prompt_orig = extend_prompt[i]
614
+ elif extend_prompt:
615
+ ext_prompt_orig = extend_prompt[-1] # Reuse last prompt
616
+ else:
617
+ ext_prompt_orig = prompt # Reuse original prompt
618
+
619
+ segment_success = False
620
+ _segment_last_error = None
621
+ for attempt in range(MAX_SEGMENT_RETRIES):
622
+ ext_prompt = ext_prompt_orig
623
+
624
+ if attempt > 0:
625
+ wait = 10 * attempt
626
+ console.print(f" [yellow]Segment {i + 1} retry {attempt}/{MAX_SEGMENT_RETRIES - 1} after {wait}s...[/yellow]")
627
+ _time.sleep(wait)
628
+
629
+ # If previous failure was a policy violation, sanitize the prompt
630
+ if _segment_last_error is not None and _segment_last_error.is_policy:
631
+ ext_prompt = _sanitize_prompt(ext_prompt_orig, attempt)
632
+ console.print(f" [cyan]Sanitized prompt:[/cyan] {ext_prompt[:80]}...")
633
+
634
+ # Track error info for this attempt
635
+ class _SegError:
636
+ is_policy = False
637
+ msg = ""
638
+ _segment_last_error = _SegError()
639
+
640
+ try:
641
+ ext_req = ExtendVideoRequest(
642
+ prompt=ext_prompt,
643
+ media_id=current_media_id,
644
+ aspect_ratio=aspect_ratio,
645
+ workflow_id=workflow_id,
646
+ seed=(seed + i + 1) if seed is not None else None,
647
+ )
648
+
649
+ # Submit extend
650
+ ext_assets = None
651
+ try:
652
+ with console.status(f"[bold green]Submitting extend ({ext_prompt[:40]})..."):
653
+ ext_assets = client.extend_video(ext_req)
654
+ except FlowAPIError as e:
655
+ err_str = str(e)
656
+ _segment_last_error.msg = err_str
657
+ _segment_last_error.is_policy = _is_policy_violation(err_str)
658
+ if _segment_last_error.is_policy:
659
+ console.print(f" [red]Policy violation on segment {i + 1}:[/red] {err_str[:120]}")
660
+ else:
661
+ console.print(f" [red]Error submitting segment {i + 1}:[/red] {err_str[:120]}")
662
+ continue # retry
663
+
664
+ if not ext_assets:
665
+ _segment_last_error.msg = "No operation returned"
666
+ console.print(f" [yellow]No operation returned for segment {i + 1}.[/yellow]")
667
+ continue # retry
668
+
669
+ # Poll/render
670
+ ext_op_names = [a.id for a in ext_assets if a.id]
671
+ try:
672
+ with console.status(f"[bold green]Rendering segment {i + 1} (1-3 min)..."):
673
+ ext_final = client.wait_for_video(ext_op_names, timeout=timeout)
674
+ except FlowAPIError as e:
675
+ err_str = str(e)
676
+ _segment_last_error.msg = err_str
677
+ _segment_last_error.is_policy = _is_policy_violation(err_str)
678
+ if _segment_last_error.is_policy:
679
+ console.print(f" [red]Policy violation rendering segment {i + 1}:[/red] {err_str[:120]}")
680
+ else:
681
+ console.print(f" [red]Error rendering segment {i + 1}:[/red] {err_str[:120]}")
682
+ continue # retry
683
+
684
+ if not ext_final:
685
+ _segment_last_error.msg = "No assets returned"
686
+ _segment_last_error.is_policy = True # often means blocked
687
+ console.print(f" [yellow]Segment {i + 1} returned no assets (possibly blocked).[/yellow]")
688
+ continue # retry
689
+
690
+ ext_asset = ext_final[0]
691
+ all_assets.append(ext_asset)
692
+
693
+ # Save segment
694
+ seg_path = out_dir / f"{prefix}-seg{i + 1}.mp4"
695
+ try:
696
+ path = client.save_video(ext_asset, seg_path)
697
+ segment_paths.append(path)
698
+ console.print(f" [green]Segment {i + 1} saved:[/green] {path}")
699
+ except Exception as e:
700
+ console.print(f" [yellow]Download failed:[/yellow] {e}")
701
+
702
+ # Update workflow for next segment chaining
703
+ new_media_name = client.get_media_name_for_op(ext_asset.id) or ext_asset.id
704
+ if client._workflow_id:
705
+ workflow_id = client._workflow_id
706
+ if workflow_id and new_media_name:
707
+ client.update_workflow(workflow_id, primary_media_id=new_media_name)
708
+ current_media_id = new_media_name
709
+ console.print(f" Media ID (primaryMedia): {current_media_id}")
710
+
711
+ segment_success = True
712
+ break # segment done, move to next
713
+
714
+ except (requests.exceptions.ConnectionError, ConnectionResetError, OSError) as e:
715
+ _segment_last_error.msg = str(e)
716
+ console.print(f" [red]Connection error on segment {i + 1}:[/red] {str(e)[:120]}")
717
+ continue # retry
718
+
719
+ except Exception as e:
720
+ _segment_last_error.msg = str(e)
721
+ _segment_last_error.is_policy = _is_policy_violation(str(e))
722
+ console.print(f" [red]Unexpected error on segment {i + 1}:[/red] {str(e)[:120]}")
723
+ continue # retry
724
+
725
+ if not segment_success:
726
+ console.print(f" [red bold]Segment {i + 1} failed after {MAX_SEGMENT_RETRIES} attempts — skipping.[/red bold]")
727
+ # Don't break — try remaining segments. The video will have a gap but at least continues.
728
+ # For chaining to work, we keep the same current_media_id (last successful segment)
729
+ continue
730
+
731
+ # ---- Summary ----
732
+ console.print(f"\n[bold green]Done![/bold green] Generated {len(all_assets)} segments.")
733
+ for p in segment_paths:
734
+ console.print(f" {p}")
735
+
736
+ if as_json:
737
+ click.echo(json.dumps([a.model_dump() for a in all_assets], indent=2, default=str))
738
+
739
+
740
+ # =============================================================
741
+ # Caption (image-to-text)
742
+ # =============================================================
743
+
744
+ @cli.command("caption")
745
+ @click.argument("image_path", type=click.Path(exists=True))
746
+ @click.option("--count", default=1, type=click.IntRange(1, 5), help="Number of captions to generate")
747
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
748
+ @click.pass_context
749
+ def caption_image(ctx, image_path, count, as_json):
750
+ """Generate a caption/description from an image file.
751
+
752
+ \b
753
+ Examples:
754
+ gflow caption photo.png
755
+ gflow caption my-image.jpg --count 3
756
+ """
757
+ client = _get_client(ctx.obj["debug"])
758
+
759
+ try:
760
+ with console.status("[bold green]Generating caption..."):
761
+ captions = client.caption_image(image_path, count=count)
762
+ except FlowAPIError as e:
763
+ console.print(f"[red]Error:[/red] {e}")
764
+ sys.exit(1)
765
+
766
+ if as_json:
767
+ click.echo(json.dumps({"captions": captions}, indent=2))
768
+ else:
769
+ for i, cap in enumerate(captions):
770
+ console.print(f"[bold]Caption {i+1}:[/bold] {cap}")
771
+
772
+
773
+ # =============================================================
774
+ # Fetch media by ID
775
+ # =============================================================
776
+
777
+ @cli.command("fetch")
778
+ @click.argument("media_id")
779
+ @click.option("-o", "--output", default=None, help="Save to file")
780
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
781
+ @click.pass_context
782
+ def fetch_media(ctx, media_id, output, as_json):
783
+ """Fetch a previously generated image/video by its media ID.
784
+
785
+ \b
786
+ Example:
787
+ gflow fetch <media-id> -o fetched-image.png
788
+ """
789
+ client = _get_client(ctx.obj["debug"])
790
+
791
+ try:
792
+ asset = client.fetch_media(media_id)
793
+ except FlowAPIError as e:
794
+ console.print(f"[red]Error:[/red] {e}")
795
+ sys.exit(1)
796
+
797
+ if output and asset.raw.get("encodedImage"):
798
+ import base64
799
+ img_bytes = base64.b64decode(asset.raw["encodedImage"])
800
+ Path(output).write_bytes(img_bytes)
801
+ console.print(f"[green]Saved:[/green] {output}")
802
+ elif output and asset.url:
803
+ path = client.download_asset(asset.url, output)
804
+ console.print(f"[green]Saved:[/green] {path}")
805
+
806
+ if as_json:
807
+ d = asset.model_dump()
808
+ if "encodedImage" in d.get("raw", {}):
809
+ d["raw"]["encodedImage"] = f"<{len(d['raw']['encodedImage'])} chars>"
810
+ click.echo(json.dumps(d, indent=2, default=str))
811
+ else:
812
+ console.print(f"[bold]Media:[/bold] {asset.id}")
813
+ console.print(f" Type: {asset.asset_type.value}")
814
+ console.print(f" Prompt: {asset.prompt}")
815
+ console.print(f" Model: {asset.model}")
816
+
817
+
818
+ # =============================================================
819
+ # User info
820
+ # =============================================================
821
+
822
+ @cli.command("whoami")
823
+ @click.pass_context
824
+ def whoami(ctx):
825
+ """Show the currently authenticated user."""
826
+ client = _get_client(ctx.obj["debug"])
827
+
828
+ try:
829
+ user = client.get_user_info()
830
+ except (FlowAPIError, AuthError) as e:
831
+ console.print(f"[red]Error:[/red] {e}")
832
+ sys.exit(1)
833
+
834
+ console.print(f"[bold]{user.get('name', 'Unknown')}[/bold]")
835
+ console.print(f" Email: {user.get('email', '?')}")
836
+ if user.get("image"):
837
+ console.print(f" Avatar: {user['image']}")
838
+
839
+
840
+ # =============================================================
841
+ # Raw request (discovery mode)
842
+ # =============================================================
843
+
844
+ @cli.command("raw")
845
+ @click.argument("method", type=click.Choice(["GET", "POST"], case_sensitive=False))
846
+ @click.argument("path")
847
+ @click.option("--data", "payload", default=None, help="JSON payload for POST")
848
+ @click.pass_context
849
+ def raw_request(ctx, method, path, payload):
850
+ """Make a raw API request (for endpoint discovery).
851
+
852
+ \b
853
+ Examples:
854
+ gflow raw GET https://labs.google/fx/api/auth/session
855
+ gflow raw POST /v1:runImageFx --data '{"userInput":{"prompts":["test"]}}'
856
+ """
857
+ client = _get_client(ctx.obj["debug"])
858
+
859
+ parsed_payload = None
860
+ if payload:
861
+ try:
862
+ parsed_payload = json.loads(payload)
863
+ except json.JSONDecodeError as e:
864
+ console.print(f"[red]Invalid JSON:[/red] {e}")
865
+ sys.exit(1)
866
+
867
+ try:
868
+ result = client.raw_request(method.upper(), path, parsed_payload)
869
+ click.echo(json.dumps(result, indent=2, default=str))
870
+ except FlowAPIError as e:
871
+ console.print(f"[red]Error:[/red] {e}")
872
+ sys.exit(1)
873
+
874
+
875
+ # =============================================================
876
+ # Network sniffer (discover what APIs Flow actually uses)
877
+ # =============================================================
878
+
879
+ @cli.command("sniff")
880
+ @click.option("--duration", default=120, type=int, help="How many seconds to capture (default: 120)")
881
+ @click.option("-o", "--output", default="gflow-network-capture.json", help="Output file for captured requests")
882
+ @click.pass_context
883
+ def sniff_network(ctx, duration, output):
884
+ """Sniff Flow's network traffic to discover real API endpoints.
885
+
886
+ \b
887
+ Opens Flow in a browser and captures all API requests while you
888
+ interact with it. Generate an image or video in the browser, then
889
+ come back here to see exactly what API calls Flow made.
890
+
891
+ \b
892
+ Steps:
893
+ 1. Run: gflow sniff
894
+ 2. In the browser, generate an image or video
895
+ 3. Wait for it to finish, then press Ctrl+C or let the timer expire
896
+ 4. Check the output file for captured API calls
897
+ """
898
+ from selenium import webdriver
899
+ from selenium.webdriver.chrome.options import Options
900
+ from selenium.webdriver.chrome.service import Service
901
+ from webdriver_manager.chrome import ChromeDriverManager
902
+ from gflow.auth.browser_auth import ENV_DIR, FLOW_URL
903
+
904
+ print()
905
+ print("=" * 60)
906
+ print(" Google Flow Network Sniffer")
907
+ print("=" * 60)
908
+ print()
909
+ print(" A browser will open with Flow loaded.")
910
+ print(" Perform any action (generate image, video, etc.)")
911
+ print(f" Network traffic will be captured for {duration}s.")
912
+ print()
913
+ print(" Press Ctrl+C to stop early.")
914
+ print()
915
+
916
+ chrome_options = Options()
917
+ chrome_options.add_argument("--no-first-run")
918
+ chrome_options.add_argument("--no-default-browser-check")
919
+ chrome_options.add_argument("--disable-blink-features=AutomationControlled")
920
+ chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
921
+ chrome_options.add_experimental_option("useAutomationExtension", False)
922
+
923
+ # Reuse the persistent profile (already logged in)
924
+ profile_dir = str(ENV_DIR / "chrome-profile")
925
+ chrome_options.add_argument(f"--user-data-dir={profile_dir}")
926
+
927
+ # Enable performance logging for network capture
928
+ chrome_options.set_capability("goog:loggingPrefs", {"performance": "ALL"})
929
+
930
+ driver = None
931
+ captured_requests = []
932
+
933
+ try:
934
+ service = Service(ChromeDriverManager().install())
935
+ driver = webdriver.Chrome(service=service, options=chrome_options)
936
+
937
+ # Enable CDP network domain
938
+ driver.execute_cdp_cmd("Network.enable", {})
939
+
940
+ driver.get(FLOW_URL)
941
+ print(" Browser opened. Perform your actions now...")
942
+ print()
943
+
944
+ import time as _time
945
+ start = _time.time()
946
+ request_bodies = {}
947
+
948
+ try:
949
+ while _time.time() - start < duration:
950
+ _time.sleep(2)
951
+
952
+ try:
953
+ logs = driver.get_log("performance")
954
+ except Exception:
955
+ logs = []
956
+
957
+ for entry in logs:
958
+ try:
959
+ msg = json.loads(entry["message"])["message"]
960
+ method = msg.get("method", "")
961
+ params = msg.get("params", {})
962
+
963
+ if method == "Network.requestWillBeSent":
964
+ request = params.get("request", {})
965
+ url = request.get("url", "")
966
+ http_method = request.get("method", "")
967
+ headers = request.get("headers", {})
968
+ post_data = request.get("postData", "")
969
+ request_id = params.get("requestId", "")
970
+
971
+ if any(ext in url for ext in [".js", ".css", ".png", ".jpg", ".svg", ".woff", ".ico"]):
972
+ continue
973
+ if any(h in url for h in ["google-analytics", "doubleclick", "googletagmanager", "play.google.com/log"]):
974
+ continue
975
+
976
+ req_data = {
977
+ "requestId": request_id,
978
+ "method": http_method,
979
+ "url": url,
980
+ "headers": dict(headers),
981
+ }
982
+
983
+ if post_data:
984
+ req_data["postData"] = post_data
985
+ if "batchexecute" in url:
986
+ req_data["type"] = "batchexecute"
987
+ elif "aisandbox" in url or "runImageFx" in url or "runVideoFx" in url:
988
+ req_data["type"] = "ai_sandbox"
989
+ try:
990
+ req_data["payload"] = json.loads(post_data)
991
+ except Exception:
992
+ pass
993
+ elif "/api/" in url:
994
+ req_data["type"] = "labs_api"
995
+ try:
996
+ req_data["payload"] = json.loads(post_data)
997
+ except Exception:
998
+ pass
999
+
1000
+ if request_id:
1001
+ request_bodies[request_id] = req_data
1002
+
1003
+ captured_requests.append(req_data)
1004
+
1005
+ if any(kw in url for kw in [
1006
+ "batchexecute", "aisandbox", "runImageFx", "runVideoFx",
1007
+ "googleapis", "/api/", "trpc",
1008
+ ]):
1009
+ elapsed = _time.time() - start
1010
+ console.print(f" [{elapsed:.0f}s] [cyan]{http_method}[/cyan] {url[:100]}")
1011
+
1012
+ elif method == "Network.responseReceived":
1013
+ request_id = params.get("requestId", "")
1014
+ response = params.get("response", {})
1015
+ if request_id in request_bodies:
1016
+ request_bodies[request_id]["response_status"] = response.get("status")
1017
+
1018
+ except (json.JSONDecodeError, KeyError):
1019
+ continue
1020
+
1021
+ except KeyboardInterrupt:
1022
+ print("\n Stopped by user.")
1023
+
1024
+ except Exception as e:
1025
+ console.print(f"[red]Error:[/red] {e}")
1026
+ sys.exit(1)
1027
+ finally:
1028
+ if driver:
1029
+ try:
1030
+ driver.quit()
1031
+ except Exception:
1032
+ pass
1033
+
1034
+ # Filter interesting requests
1035
+ interesting = [r for r in captured_requests if any(
1036
+ kw in r.get("url", "") for kw in [
1037
+ "batchexecute", "aisandbox", "runImageFx", "runVideoFx",
1038
+ "googleapis.com/v", "/api/", "trpc", "labs.google/fx",
1039
+ ]
1040
+ )]
1041
+
1042
+ output_data = {
1043
+ "total_requests": len(captured_requests),
1044
+ "interesting_requests": len(interesting),
1045
+ "interesting": interesting,
1046
+ "all_requests": captured_requests,
1047
+ }
1048
+
1049
+ Path(output).write_text(json.dumps(output_data, indent=2, default=str))
1050
+ console.print(f"\n[green]Saved {len(captured_requests)} requests ({len(interesting)} interesting) to {output}[/green]")
1051
+
1052
+ if interesting:
1053
+ print()
1054
+ table = Table(title="Interesting API Calls Found")
1055
+ table.add_column("Method", style="cyan", width=6)
1056
+ table.add_column("URL", style="white", max_width=80)
1057
+ table.add_column("Type", style="magenta")
1058
+
1059
+ for r in interesting[:20]:
1060
+ table.add_row(
1061
+ r.get("method", "?"),
1062
+ r.get("url", "")[:80],
1063
+ r.get("type", ""),
1064
+ )
1065
+ console.print(table)
1066
+ else:
1067
+ console.print("[yellow]No interesting API calls captured.[/yellow]")
1068
+
1069
+
1070
+ # =============================================================
1071
+ # Entry point
1072
+ # =============================================================
1073
+
1074
+ if __name__ == "__main__":
1075
+ cli()