Skill: liujuntao123/auto-free-banana (version 1.0.0)

--- name: auto-free-banana description: Generates images in Google Flow (labs.google/fx) through browser UI automation. Supports Nano Banana 2 and Nano Banana Pro with landscape/portrait aspect ratios. Use when the user requests Flow-based image generation. version: 1.1.0 metadata: openclaw: homepage: https://github.com/JimLiu/baoyu-skills#auto-free-banana requires: anyBins: - bun - npx --- # Google Flow UI Client Generate images in Google Flow through Chrome CDP + browser UI automation. Important constraints: - Project creation is **UI-only** - Prompt submission is **UI-only** - Do **not** use or describe any API-based project creation path - Images are considered complete once the base images are visible in the browser ## Script Directory Resolve: 1. `{baseDir}` = directory containing this `SKILL.md` 2. Script path = `{baseDir}/scripts/main.ts` 3. `${BUN_X}` = `bun` if installed, else `npx -y bun` ## Consent Check (REQUIRED) Before first use, verify user consent for reverse-engineered API usage. **Consent file locations**: - macOS: `~/Library/Application Support/baoyu-skills/flow-web/consent.json` - Linux: `~/.local/share/baoyu-skills/flow-web/consent.json` - Windows: `%APPDATA%\baoyu-skills\flow-web\consent.json` **Flow**: 1. Check if consent file exists with `accepted: true` and `disclaimerVersion: "1.0"` 2. If valid consent exists → print warning with `acceptedAt` date, proceed 3. If no consent → show disclaimer, ask user via `AskUserQuestion`: - "Yes, I accept" → create consent file with ISO timestamp, proceed - "No, I decline" → output decline message, stop 4. Consent file format: `{"version":1,"accepted":true,"acceptedAt":"<ISO>","disclaimerVersion":"1.0"}` --- ## Preferences (EXTEND.md) Check EXTEND.md existence (priority order): ```bash test -f .baoyu-skills/auto-free-banana/EXTEND.md && echo "project" test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/auto-free-banana/EXTEND.md" && echo "xdg" test -f "$HOME/.baoyu-skills/auto-free-banana/EXTEND.md" && echo "user" ``` **EXTEND.md Supports**: Default model | Default aspect ratio | Custom data directory ## Execution Envelope (REQUIRED) Before any pre-flight check or `main.ts` invocation, decide one shell environment and reuse it for **every** command in the run. - Use the same `PATH` - Use the same proxy variables - Use the same CDP / Chrome profile variables If you set any of these once, keep them identical for all later commands in the turn: - `PATH` - `FLOW_WEB_PROXY` - `HTTPS_PROXY` / `HTTP_PROXY` - [REDACTED_SECRET] - `FLOW_WEB_DEBUG_PORT` / `AGENT_BROWSER_CDP_PORT` - `FLOW_WEB_COOKIE_PATH` - `FLOW_WEB_CHROME_PROFILE_DIR` - `AGENT_BROWSER_USER_DATA_DIR_WIN` - `AGENT_BROWSER_CMD_EXE_WSL` Never run pre-flight checks in one environment and `main.ts` in another. Preferred proxy setup: ```bash export FLOW_WEB_PROXY=http://host:port export HTTPS_PROXY="$FLOW_WEB_PROXY" export HTTP_PROXY="$FLOW_WEB_PROXY" export AGENT_BROWSER_CHROME_PROXY_SERVER="$FLOW_WEB_PROXY" ``` ## Usage Before running any generation command, complete the [Pre-flight Checks](#pre-flight-checks) below. ```bash # Default generation: creates a NEW Flow project through the UI ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cute cat" # Specify model + portrait + 2 images ${BUN_X} {baseDir}/scripts/main.ts --prompt "A sunset" --model NANO_BANANA_PRO --aspect 9:16 --count 2 # Reuse an EXISTING Flow project only when you already have the project id ${BUN_X} {baseDir}/scripts/main.ts --project-id <project-id> --prompt "A sunset" # Authentication only ${BUN_X} {baseDir}/scripts/main.ts --login # Batch mode: multiple prompts in one project (5s interval) ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cute cat" --prompt "A sunset" --prompt "A mountain landscape" ``` Batch mode rules: - Use one command with repeated `--prompt` - The first prompt creates a new project through the UI unless `--project-id` is supplied - Later prompts reuse that same project - Do not run multiple separate `main.ts` commands for a multi-prompt batch --- ## Pre-flight Checks Run these checks **in order**. Each step must pass before moving to the next. ### Step 1: Runtime Prerequisites (BLOCKING) Check the Bun runtime and Python: ```bash command -v bun >/dev/null && echo "BUN" || command -v npx >/dev/null && echo "NPX_BUN" || echo "MISSING" command -v python3 >/dev/null && echo "OK" || echo "MISSING" ``` If any required command is missing, stop. If running under WSL, also verify the Windows bridge and Chrome path assumptions: ```bash test -x /mnt/c/Windows/System32/cmd.exe && echo "OK" || echo "MISSING" test -f "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" && echo "OK" || echo "MISSING" ``` If not running under WSL, ensure `FLOW_WEB_CHROME_PATH` is set before continuing. ### Step 2: Browser Profile Check (AUTO-RESOLVE) Check if the Chrome debug user data directory exists. Default WSL-visible path: - `/mnt/c/chrome-debug-openclaw` - env override: `AGENT_BROWSER_USER_DATA_DIR_WIN` ```bash test -d /mnt/c/chrome-debug-openclaw && echo "EXISTS" || echo "NEW" ``` - `EXISTS` → proceed - `NEW` → proceed, but tell the user a fresh browser profile will be created ### Step 3: Network Connectivity Check (WARN + WAIT) Test Google reachability using the **same environment** you will use for `main.ts`: ```bash curl -sS --connect-timeout 5 -o /dev/null -w '%{http_code}' https://labs.google/fx/ 2>/dev/null || echo "FAIL" ``` - `200/301/302` → proceed - `FAIL / timeout` → stop and fix proxy settings first Do not continue into `main.ts` while shell networking and browser proxy settings disagree. ### Step 4: Login State Check (WARN + GUIDE) Check if cached credentials exist: Linux default cookie file: - `~/.local/share/baoyu-skills/flow-web/cookies.json` ```bash test -f ~/.local/share/baoyu-skills/flow-web/cookies.json && echo "EXISTS" || echo "NONE" ``` - `EXISTS` → proceed - `NONE` → run: ```bash ${BUN_X} {baseDir}/scripts/main.ts --login --verbose ``` Then wait for the user to finish Google login in the browser. --- ## Generation Rules The skill supports exactly two generation entry modes: - New project: omit `--project-id`; the script creates a project through the **Flow UI** - Existing project: provide `--project-id` Do not invent any third path. Explicitly forbidden: - API-based project creation - Describing API project creation as a fallback - Mixing one failed path with a different project-creation mechanism mid-run If automatic UI project creation fails: 1. Inspect the Flow listing page in the browser 2. Fix the UI state only 3. Retry the same UI path 4. If needed, manually click `新建项目` in the browser, capture the resulting project id from the URL, then rerun `main.ts` with `--project-id <id>` The fallback is still UI-only. --- ## Task Completion (IMPORTANT) `main.ts` only automates the UI. It fills the prompt, clicks Create, then exits immediately. Actual image generation continues asynchronously in the browser. **After `main.ts` exits with code 0:** 1. The script has successfully submitted all prompts. Images are now **generating in the browser**. 2. You MUST verify generation completion by **taking a browser screenshot** (via CDP or screenshot tool) and visually checking whether images have appeared on the page. 3. If the screenshot shows images are still loading (spinner, progress indicator, blank placeholders), **wait 10-15 seconds** and take another screenshot. 4. Repeat until all images are visible, or until 2 minutes have passed (then report a timeout). 5. Once the base images are confirmed visible, stop and report completion. 6. **Do NOT** download, fetch, curl, or render any URLs. Images are visible in the browser. Important completion rule: - If the images are visible **and** Flow still shows optional HD-enhancement / download / post-processing toasts, treat the task as complete anyway. **If the script exits with a non-zero code**, report the error and stop. --- ## Options | Option | Description | |--------|-------------| | `--prompt`, `-p` | Prompt text (repeat for batch mode) | | `--model`, `-m` | Model ID (see Models table) | | `--aspect`, `-a` | Aspect ratio: 16:9 (default), 9:16 | | `--count` | Number of images: 1-4 (default: 2) | | `--project-id` | Reuse existing project | | `--verbose` | Enable debug logging | | `--login` | Authenticate and save cookies, then exit | | `--cookie-path` | Custom cookie file path | | `--profile-dir` | Chrome profile directory | ## Models | Model | Description | |-------|-------------| | `NARWHAL` | Nano Banana 2 (default, fast) | | `NANO_BANANA_PRO` | Nano Banana Pro | ## Optional Verification Helper If `/home/admin1/.openclaw/workspace/scripts/agent-browser-cdp.sh` exists, it is a convenient helper for screenshots and browser inspection after generation: ```bash bash /home/admin1/.openclaw/workspace/scripts/agent-browser-cdp.sh --full screenshot /tmp/flow.png bash /home/admin1/.openclaw/workspace/scripts/agent-browser-cdp.sh snapshot -c ``` This helper is optional. Generation must not depend on `agent-browser` being installed. ## Environment Variables | Variable | Description | |----------|-------------| | `FLOW_WEB_PROXY` | Preferred proxy for shell/API requests | | `HTTPS_PROXY` / `HTTP_PROXY` | Standard proxy env vars | | `FLOW_WEB_DATA_DIR` | Data directory | | `FLOW_WEB_COOKIE_PATH` | Cookie file path | | `FLOW_WEB_CHROME_PROFILE_DIR` | Local Chrome user data dir when not on WSL | | `FLOW_WEB_CHROME_PATH` | Chrome executable path for non-WSL environments | | `FLOW_WEB_DEBUG_PORT` | Fixed Chrome debug port | | `FLOW_WEB_CHROME_EXE_WIN` | Windows Chrome path override when running from WSL | | `FLOW_WEB_CHROME_USER_DATA_DIR_WIN` | Windows Chrome user data dir override when running from WSL | | `FLOW_WEB_CHROME_EXTRA_ARGS` | Extra Chrome args | | `AGENT_BROWSER_CHROME_PROXY_SERVER` | Browser proxy used by host Chrome | | `AGENT_BROWSER_USER_DATA_DIR_WIN` | Windows-side Chrome debug profile override | | `AGENT_BROWSER_CMD_EXE_WSL` | WSL path to `cmd.exe` | | `BAOYU_CHROME_PROFILE_DIR` | Shared Chrome profile override |

path: scripts/flow-webapi/client.ts
content: import { resolveImageAspectRatio, resolveImageModel, ImageModel, ModelDisplayName } from './constants.js';
import { AuthError, GenerationError } from './exceptions.js';
import { get_auth_token, type AuthResult } from './utils/get-auth-token.js';
import { type CookieMap } from './utils/cookie-file.js';
import { sleep } from './utils/http.js';
import { logger } from './utils/logger.js';
import {
  CdpConnection,
  get_cdp_port,
  wait_for_chrome_debug_port,
  ensure_chrome_cdp,
} from './utils/load-browser-cookies.js';

export type FlowClientOptions = {
  verbose?: boolean;
};

export type GenerateImagesOptions = {
  prompt: string;
  model?: string;
  aspect?: string;
  count?: number;
  projectId?: string;
};

export type GenerationResult = {
  projectId: string;
};

export class FlowClient {
  public accessToken: string | null = null;
  public cookies: CookieMap = {};
  public verbose: boolean;

  private cdp: CdpConnection | null = null;
  private cdpSessionId: string | null = null;
  private _initialized = false;

  constructor(opts: FlowClientOptions = {}) {
    this.verbose = opts.verbose ?? false;
  }

  /** Initialize: get OAuth token + connect CDP */
  async init(): Promise<void> {
    if (this._initialized) return;

    // Get auth token
    let auth: AuthResult;
    try {
      auth = await get_auth_token(this.verbose);
    } catch (e) {
      throw new AuthError(`Failed to authenticate: ${e instanceof Error ? e.message : String(e)}`);
    }

    this.accessToken = auth.accessToken;
    this.cookies = auth.cookies;

    // Connect to Chrome CDP for UI automation
    await this.ensureCdp();

    this._initialized = true;
    logger.success('Flow client initialized (UI automation mode).');
  }

  private async ensureCdp(): Promise<void> {
    if (this.cdp && this.cdpSessionId) return;

    const port = get_cdp_port();
    await ensure_chrome_cdp(this.verbose);
    const wsUrl = await wait_for_chrome_debug_port(port, 30_000);
    this.cdp = await CdpConnection.connect(wsUrl, 15_000);

    // Find or create a Flow tab
    const targets = await this.cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
    let flowTab = targets.targetInfos.find(t => t.type === 'page' && t.url.includes('labs.google/fx'));

    let targetId: string;
    if (flowTab) {
      targetId = flowTab.targetId;
      if (this.verbose) logger.debug(`Reusing existing Flow tab: ${flowTab.url}`);
    } else {
      const res = await this.cdp.send<{ targetId: string }>('Target.createTarget', {
        url: 'https://labs.google/fx/zh/tools/flow',
        newWindow: false,
      });
      targetId = res.targetId;
      await sleep(8000); // Wait for page to load
    }

    const { sessionId } = await this.cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true });
    this.cdpSessionId = sessionId;
    await this.cdp.send('Runtime.enable', {}, { sessionId });
  }

  private async getCurrentUrl(): Promise<string> {
    if (!this.cdp || !this.cdpSessionId) throw new Error('CDP not connected');

    return (await this.cdp.send<{ result: { value?: string } }>('Runtime.evaluate', {
      expression: 'location.href',
      returnByValue: true,
    }, { sessionId: this.cdpSessionId }))?.result?.value || '';
  }

  private async waitForProjectUrl(timeoutMs: number): Promise<string | null> {
    const start = Date.now();

    while (Date.now() - start < timeoutMs) {
      const url = await this.getCurrentUrl();
      const projectId = url.match(/project\/([a-f0-9-]+)/)?.[1];
      if (projectId) return projectId;
      await sleep(500);
    }

    return null;
  }

  private async waitForListingCreateButton(timeoutMs: number): Promise<void> {
    if (!this.cdp || !this.cdpSessionId) throw new Error('CDP not connected');

    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
      const status = (await this.cdp.send<{ result: { value?: string } }>('Runtime.evaluate', {
        expression: `(() => {
          const urlOk = location.href.includes('/tools/flow');
          const ready = document.readyState === 'complete' || document.readyState === 'interactive';
          const hasCreate = Array.from(document.querySelectorAll('a, button')).some((el) => {
            const text = (el.textContent || '').replace(/\\s+/g, ' ').trim();
            return text.includes('新建项目') || text.includes('New project') || text.includes('Create project');
          });
          return urlOk && ready && hasCreate ? 'ready' : 'waiting';
        })()`,
        returnByValue: true,
      }, { sessionId: this.cdpSessionId }))?.result?.value;

      if (status === 'ready') return;
      await sleep(500);
    }

    throw new GenerationError('Flow listing did not become ready for UI project creation.');
  }

  private async clickCreateProjectButton(): Promise<boolean> {
    if (!this.cdp || !this.cdpSessionId) throw new Error('CDP not connected');

    const result = (await this.cdp.send<{ result: { value?: string } }>('Runtime.evaluate', {
      expression: `(() => {
        const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
        const isVisible = (el) => !!el && !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);

        const candidates = Array.from(document.querySelectorAll('a, button'));
        for (const el of candidates) {
          const text = normalize(el.textContent);
          if (!isVisible(el)) continue;
          if (text.includes('新建项目') || text.includes('New project') || text.includes('Create project')) {
            el.click();
            return 'clicked';
          }
        }

        return 'not-found';
      })()`,
      returnByValue: true,
    }, { sessionId: this.cdpSessionId }))?.result?.value;

    return result === 'clicked';
  }

  /**
   * Navigate to a project page in the browser and wait for it to load.
   */
  private async navigateToProject(projectId: string): Promise<void> {
    if (!this.cdp || !this.cdpSessionId) throw new Error('CDP not connected');

    const url = `https://labs.google/fx/zh/tools/flow/project/${projectId}`;
    if (this.verbose) logger.debug(`Navigating to project: ${projectId}`);

    const currentUrl = await this.getCurrentUrl();
    if (currentUrl.includes(`/project/${projectId}`)) {
      if (this.verbose) logger.debug('Already on project page');
      return;
    }

    await this.cdp.send('Page.navigate', { url }, { sessionId: this.cdpSessionId });

    const resolvedProjectId = await this.waitForProjectUrl(20_000);
    if (resolvedProjectId !== projectId) {
      const finalUrl = await this.getCurrentUrl();
      throw new GenerationError(`Failed to navigate to project page. URL: ${finalUrl}`);
    }
  }

  /**
   * Navigate to Flow listing and create a project through the UI.
   */
  private async createProjectViaUI(): Promise<string> {
    if (!this.cdp || !this.cdpSessionId) throw new Error('CDP not connected');

    for (let attempt = 1; attempt <= 2; attempt++) {
      await this.cdp.send('Page.navigate', {
        url: 'https://labs.google/fx/zh/tools/flow',
      }, { sessionId: this.cdpSessionId });

      await this.waitForListingCreateButton(20_000);

      const clicked = await this.clickCreateProjectButton();
      if (!clicked) {
        throw new GenerationError('Could not find the UI "新建项目" button on the Flow listing page.');
      }

      const projectId = await this.waitForProjectUrl(20_000);
      if (projectId) {
        if (this.verbose) logger.debug(`Created project via UI: ${projectId}`);
        return projectId;
      }

      if (this.verbose) logger.debug(`UI project creation attempt ${attempt} did not reach a project page, retrying...`);
    }

    const finalUrl = await this.getCurrentUrl();
    throw new GenerationError(`Failed to create project via UI. URL: ${finalUrl}`);
  }

  /**
   * Configure the UI: select direction (横向/纵向), count (x1-x4), and model.
   */
  private async configureUI(model: string, aspect: string, count: number): Promise<void> {
    if (!this.cdp || !this.cdpSessionId) throw new Error('CDP not connected');

    const modelDisplay = ModelDisplayName[model] || model;
    const isPortrait = aspect.includes('PORTRAIT');

    if (this.verbose) logger.debug(`Configuring UI: model=${modelDisplay}, aspect=${isPortrait ? '纵向' : '横向'}, count=x${count}`);

    // 1. Direction: click 横向 or 纵向 button
    const dirLabel = isPortrait ? '纵向' : '横向';
    await this.cdp.send('Runtime.evaluate', {
      expression: `(() => {
        for (const btn of document.querySelectorAll('button')) {
          const t = btn.textContent?.trim();
          if (t === '${dirLabel}') { btn.click(); return 'clicked'; }
        }
        return 'not-found';
      })()`,
      returnByValue: true,
    }, { sessionId: this.cdpSessionId });
    await sleep(500);

    // 2. Count: click x1/x2/x3/x4 button
    const countLabel = `x${count}`;
    await this.cdp.send('Runtime.evaluate', {
      expression: `(() => {
        for (const btn of document.querySelectorAll('button')) {
          const t = btn.textContent?.trim();
          if (t === '${countLabel}') { btn.click(); return 'clicked'; }
        }
        return 'not-found';
      })()`,
      returnByValue: true,
    }, { sessionId: this.cdpSessionId });
    await sleep(500);

    // 3. Model: click current model button to open dropdown, then select target
    // a. Find and click the model selector button (contains "Nano Banana")
    const openResult = (await this.cdp.send<{ result: { value?: string } }>('Runtime.evaluate', {
      expression: `(() => {
        for (const btn of document.querySelectorAll('button')) {
          const t = btn.textContent?.trim();
          if (t && t.includes('Nano Banana')) { btn.click(); return 'opened'; }
        }
        return 'not-found';
      })()`,
      returnByValue: true,
    }, { sessionId: this.cdpSessionId }))?.result?.value;

    if (openResult === 'opened') {
      await sleep(500);

      // b. Click the target model in the dropdown
      const targetModelName = modelDisplay; // e.g. "Nano Banana 2" or "Nano Banana Pro"
      await this.cdp.send('Runtime.evaluate', {
        expression: `(() => {
          // Look in dropdown/menu items for the target model name
          const candidates = document.querySelectorAll('[role="option"], [role="menuitem"], [role="listbox"] *, li, button');
          for (const el of candidates) {
            const t = el.textContent?.trim();
            if (t && t.includes('${targetModelName}')) { el.click(); return 'selected'; }
          }
          return 'not-found';
        })()`,
        returnByValue: true,
      }, { sessionId: this.cdpSessionId });
      await sleep(500);
    }

    if (this.verbose) logger.debug('UI configured');
  }

  /**
   * Input prompt into the Slate editor via CDP.
   */
  private async inputPrompt(prompt: string): Promise<void> {
    if (!this.cdp || !this.cdpSessionId) throw new Error('CDP not connected');

    // Focus the Slate editor
    await this.cdp.send('Runtime.evaluate', {
      expression: `document.querySelector('[data-slate-editor]')?.focus()`,
      returnByValue: true,
    }, { sessionId: this.cdpSessionId });
    await sleep(300);

    // Select all existing text
    await this.cdp.send('Input.dispatchKeyEvent', {
      type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2,
      windowsVirtualKeyCode: 65, nativeVirtualKeyCode: 65,
    }, { sessionId: this.cdpSessionId });
    await this.cdp.send('Input.dispatchKeyEvent', {
      type: 'keyUp', key: 'a', code: 'KeyA', modifiers: 2,
      windowsVirtualKeyCode: 65, nativeVirtualKeyCode: 65,
    }, { sessionId: this.cdpSessionId });
    await sleep(200);

    // Insert the prompt text
    await this.cdp.send('Input.insertText', { text: prompt }, { sessionId: this.cdpSessionId });
    await sleep(500);

    // Verify
    const editorText = (await this.cdp.send<{ result: { value?: string } }>('Runtime.evaluate', {
      expression: `document.querySelector('[data-slate-editor]')?.textContent || ''`,
      returnByValue: true,
    }, { sessionId: this.cdpSessionId }))?.result?.value || '';

    if (!editorText.includes(prompt.slice(0, 20))) {
      throw new GenerationError('Failed to input prompt into editor');
    }

    if (this.verbose) logger.debug(`Prompt entered: "${prompt.slice(0, 50)}..."`);
  }

  /**
   * Click the Create/Generate button.
   * Returns immediately after confirming the button was clicked.
   * Completion detection is handled by the LLM via browser screenshots.
   */
  private async clickCreate(): Promise<void> {
    if (!this.cdp || !this.cdpSessionId) throw new Error('CDP not connected');

    const clickResult = (await this.cdp.send<{ result: { value?: string } }>('Runtime.evaluate', {
      expression: `(() => {
        for (const btn of document.querySelectorAll('button')) {
          const t = btn.textContent?.trim();
          if (t?.includes('arrow_forward') && t?.includes('创建')) { btn.click(); return 'clicked'; }
        }
        // Fallback
        for (const btn of document.querySelectorAll('button')) {
          const t = btn.textContent?.trim();
          if (t?.includes('Create') || t?.includes('Generate')) { btn.click(); return 'clicked-fallback'; }
        }
        return 'not-found';
      })()`,
      returnByValue: true,
    }, { sessionId: this.cdpSessionId }))?.result?.value;

    if (clickResult === 'not-found') {
      throw new GenerationError('Create button not found on page');
    }

    if (this.verbose) logger.debug('Create button clicked');
  }

  /**
   * Generate images via UI automation.
   * Creates or reuses a Flow project through the UI, configures options, inputs the prompt, and clicks Create.
   */
  async generateImages(opts: GenerateImagesOptions): Promise<GenerationResult> {
    await this.ensureInit();

    const model = resolveImageModel(opts.model ?? ImageModel.NARWHAL);
    const aspect = resolveImageAspectRatio(opts.aspect ?? '16:9');
    const count = opts.count ?? 2;

    // Create project via UI (ensures proper page state)
    const projectId = opts.projectId ?? await this.createProjectViaUI();

    // Navigate to project if needed
    await this.navigateToProject(projectId);

    // Configure direction, count, and model in the UI
    await this.configureUI(model, aspect, count);

    // Input prompt
    await this.inputPrompt(opts.prompt);

    // Click create button (completion is detected by LLM via screenshots)
    logger.info(`Generating image: "${opts.prompt.slice(0, 60)}..."`);
    await this.clickCreate();

    logger.success('Generation complete.');

    return { projectId };
  }

  private async ensureInit(): Promise<void> {
    if (!this._initialized) await this.init();
    if (!this.accessToken) throw new AuthError('Not authenticated');
  }

  /** Clean up CDP connection */
  async close(): Promise<void> {
    this._initialized = false;
    this.accessToken = null;

    if (this.cdp) {
      this.cdp.close();
      this.cdp = null;
    }
    this.cdpSessionId = null;
  }
}


path: scripts/flow-webapi/constants.ts
content: /** Image generation model names */
export const ImageModel = {
  /** Nano Banana 2 — default, fast */
  NARWHAL: 'NARWHAL',
  /** Nano Banana Pro */
  NANO_BANANA_PRO: 'NANO_BANANA_PRO',
} as const;

/** All model IDs to user-friendly names */
export const ModelDisplayName: Record<string, string> = {
  NARWHAL: 'Nano Banana 2',
  NANO_BANANA_PRO: 'Nano Banana Pro',
};

/** Image aspect ratios */
export const ImageAspectRatio = {
  LANDSCAPE: 'IMAGE_ASPECT_RATIO_LANDSCAPE',      // 16:9
  PORTRAIT: 'IMAGE_ASPECT_RATIO_PORTRAIT',         // 9:16
} as const;

/** Map user-friendly aspect ratio to API value */
export function resolveImageAspectRatio(input: string): string {
  const k = input.trim().toLowerCase();
  if (k === '16:9' || k === 'landscape') return ImageAspectRatio.LANDSCAPE;
  if (k === '9:16' || k === 'portrait') return ImageAspectRatio.PORTRAIT;
  // Pass through if already an API value
  if (k.startsWith('IMAGE_ASPECT_RATIO_')) return input;
  return ImageAspectRatio.LANDSCAPE;
}

/** Resolve user-friendly model name to API model ID */
export function resolveImageModel(input: string): string {
  const k = input.trim().toLowerCase();
  if (k === 'narwhal' || k === 'nano-banana' || k === 'nano_banana' || k === 'nano-banana-2') return ImageModel.NARWHAL;
  if (k === 'nano_banana_pro' || k === 'nano-banana-pro') return ImageModel.NANO_BANANA_PRO;
  // Fallback: use as-is (may be a new model)
  return input.toUpperCase();
}


path: scripts/flow-webapi/exceptions.ts
content: export class FlowError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'FlowError';
  }
}

export class AuthError extends FlowError {
  constructor(message: string) {
    super(message);
    this.name = 'AuthError';
  }
}

export class TimeoutError extends FlowError {
  constructor(message: string) {
    super(message);
    this.name = 'TimeoutError';
  }
}

export class QuotaExceeded extends FlowError {
  constructor(message: string) {
    super(message);
    this.name = 'QuotaExceeded';
  }
}

export class GenerationError extends FlowError {
  constructor(message: string) {
    super(message);
    this.name = 'GenerationError';
  }
}


path: scripts/flow-webapi/index.ts
content: export { FlowClient } from './client.js';
export type {
  FlowClientOptions,
  GenerateImagesOptions,
  GenerationResult,
} from './client.js';

export {
  FlowError,
  AuthError,
  TimeoutError,
  QuotaExceeded,
  GenerationError,
} from './exceptions.js';

export {
  ImageModel,
  ModelDisplayName,
  ImageAspectRatio,
  resolveImageAspectRatio,
  resolveImageModel,
} from './constants.js';

export type {
  Project,
  ProjectInitialData,
  CreditsInfo,
} from './types/index.js';

export { logger, set_log_level, setLogLevel } from './utils/index.js';

export * as utils from './utils/index.js';


path: scripts/flow-webapi/types/index.ts
content: export type { Project, ProjectInitialData, CreditsInfo } from './project.js';


path: scripts/flow-webapi/types/project.ts
content: export type Project = {
  id: string;
  title: string;
  createdAt?: string;
};

/** tRPC project initial data response */
export type ProjectInitialData = {
  project: Project;
  credits?: CreditsInfo;
};

export type CreditsInfo = {
  totalCredits?: number;
  usedCredits?: number;
  remainingCredits?: number;
};


path: scripts/flow-webapi/utils/cookie-file.ts
content: import fs from 'node:fs';
import path from 'node:path';
import { mkdir, readFile, writeFile } from 'node:fs/promises';

import { resolveFlowWebCookiePath } from './paths.js';

export type CookieMap = Record<string, string>;

export type FlowCookieFileData = {
  version: number;
  updatedAt: string;
  accessToken: string;
  cookies: CookieMap;
  source?: string;
};

export async function read_cookie_file(p: string = resolveFlowWebCookiePath()): Promise<FlowCookieFileData | null> {
  try {
    if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return null;
    const raw = await readFile(p, 'utf8');
    const data = JSON.parse(raw) as unknown;

    if (data && typeof data === 'object' && 'accessToken' in (data as any)) {
      const d = data as FlowCookieFileData;
      if (typeof d.accessToken === 'string' && d.accessToken.startsWith('ya29.')) {
        return d;
      }
    }

    return null;
  } catch {
    return null;
  }
}

export async function write_cookie_file(
  accessToken: string,
  cookies: CookieMap,
  p: string = resolveFlowWebCookiePath(),
  source?: string,
): Promise<void> {
  const dir = path.dirname(p);
  await mkdir(dir, { recursive: true });

  const payload: FlowCookieFileData = {
    version: 1,
    updatedAt: new Date().toISOString(),
    accessToken,
    cookies,
    source,
  };
  await writeFile(p, JSON.stringify(payload, null, 2), 'utf8');
}

export const readCookieFile = read_cookie_file;
export const writeCookieFile = write_cookie_file;


path: scripts/flow-webapi/utils/get-auth-token.ts
content: import process from 'node:process';

import { AuthError } from '../exceptions.js';
import { cookie_header, fetch_with_timeout } from './http.js';
import { logger } from './logger.js';
import { read_cookie_file, write_cookie_file, type CookieMap } from './cookie-file.js';
import { resolveFlowWebCookiePath } from './paths.js';
import { load_browser_auth, type FlowAuthResult } from './load-browser-cookies.js';

/**
 * Try to refresh the access token using the session API with cookies.
 */
async function refresh_token_via_session(cookies: CookieMap, verbose: boolean): Promise<string | null> {
  try {
    const res = await fetch_with_timeout('https://labs.google/fx/api/auth/session', {
      method: 'GET',
      headers: {
        'Cookie': cookie_header(cookies),
        'Accept': 'application/json',
      },
      redirect: 'follow',
      timeout_ms: 15_000,
    });

    if (!res.ok) {
      if (verbose) logger.debug(`Session refresh failed: ${res.status} ${res.statusText}`);
      return null;
    }

    const data = await res.json() as Record<string, unknown>;
    const [REDACTED_SECRET] ?? data.accessToken) as string | undefined;
    if (typeof token === 'string' && token.startsWith('ya29.')) {
      if (verbose) logger.debug('Token refreshed via /fx/api/auth/session');
      return token;
    }

    return null;
  } catch (e) {
    if (verbose) logger.debug(`Session refresh error: ${e instanceof Error ? e.message : String(e)}`);
    return null;
  }
}

export type AuthResult = {
  accessToken: string;
  cookies: CookieMap;
};

/**
 * Get a valid OAuth access token for Flow API.
 *
 * Strategy: cached file → refresh via session API → Chrome CDP login
 */
export async function get_auth_token(verbose: boolean = false): Promise<AuthResult> {
  const forceLogin = !!(process.env.FLOW_WEB_LOGIN?.trim() || process.env.FLOW_WEB_FORCE_LOGIN?.trim());

  if (!forceLogin) {
    // Try cached token
    const cached = await read_cookie_file();
    if (cached) {
      // Check if token might still be valid (we can't tell without trying)
      if (verbose) logger.debug('Found cached token, checking...');

      // Try refreshing with cookies to get a fresh token
      const refreshed = await refresh_token_via_session(cached.cookies, verbose);
      if (refreshed) {
        await write_cookie_file(refreshed, cached.cookies, resolveFlowWebCookiePath(), 'refresh').catch(() => {});
        return { accessToken: refreshed, cookies: cached.cookies };
      }

      // Use cached token as-is (may still be valid)
      if (cached.accessToken.startsWith('ya29.')) {
        if (verbose) logger.debug('Using cached access token (could not refresh, may still be valid)');
        return { accessToken: cached.accessToken, cookies: cached.cookies };
      }
    }
  }

  // Fallback: Chrome CDP login
  if (verbose) logger.info('Starting Chrome CDP auth flow...');
  let result: FlowAuthResult;
  try {
    result = await load_browser_auth(verbose);
  } catch (e) {
    throw new AuthError(
      `Failed to authenticate with Flow. ${e instanceof Error ? e.message : String(e)}`,
    );
  }

  if (!result.accessToken || !result.accessToken.startsWith('ya29.')) {
    throw new AuthError('Failed to obtain a valid OAuth access token from Chrome.');
  }

  return { accessToken: result.accessToken, cookies: result.cookies };
}

export const getAuthToken = get_auth_token;


path: scripts/flow-webapi/utils/http.ts
content: import process from 'node:process';

export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve) => {
    const t = setTimeout(() => {
      if (signal) signal.removeEventListener('abort', onAbort);
      resolve();
    }, ms);

    const onAbort = () => {
      clearTimeout(t);
      if (signal) signal.removeEventListener('abort', onAbort);
      resolve();
    };

    if (signal) {
      if (signal.aborted) {
        onAbort();
      } else {
        signal.addEventListener('abort', onAbort, { once: true });
      }
    }
  });
}

export function cookie_header(cookies: Record<string, string>): string {
  return Object.entries(cookies)
    .filter(([, v]) => typeof v === 'string' && v.length > 0)
    .map(([k, v]) => `${k}=${v}`)
    .join('; ');
}

export const cookieHeader = cookie_header;

/**
 * Resolve proxy URL from env vars.
 * Priority: FLOW_WEB_PROXY > HTTPS_PROXY > HTTP_PROXY > AGENT_BROWSER_CHROME_PROXY_SERVER
 */
export function resolve_proxy(): string | null {
  const p =
    process.env.FLOW_WEB_PROXY?.trim() ||
    process.env.HTTPS_PROXY?.trim() ||
    process.env.HTTP_PROXY?.trim() ||
    process.env.https_proxy?.trim() ||
    process.env.http_proxy?.trim() ||
    process.env.AGENT_BROWSER_CHROME_PROXY_SERVER?.trim();
  return p || null;
}

export async function fetch_with_timeout(
  url: string,
  init: RequestInit & { timeout_ms?: number } = {},
): Promise<Response> {
  const { timeout_ms, ...rest } = init;

  // Apply proxy if available (Bun supports the proxy option)
  const proxy = resolve_proxy();
  const fetchOpts: any = { ...rest };
  if (proxy) fetchOpts.proxy = proxy;

  if (!timeout_ms || timeout_ms <= 0) return fetch(url, fetchOpts);

  const ctl = new AbortController();
  const t = setTimeout(() => ctl.abort(), timeout_ms);
  try {
    return await fetch(url, { ...fetchOpts, signal: ctl.signal });
  } finally {
    clearTimeout(t);
  }
}

export const fetchWithTimeout = fetch_with_timeout;


path: scripts/flow-webapi/utils/index.ts
content: export { logger, set_log_level, setLogLevel } from './logger.js';
export type { LogLevel } from './logger.js';

export { sleep, cookie_header, cookieHeader, fetch_with_timeout, fetchWithTimeout } from './http.js';

export {
  resolveUserDataRoot,
  resolveFlowWebDataDir,
  resolveFlowWebCookiePath,
  resolveFlowWebChromeProfileDir,
} from './paths.js';

export {
  read_cookie_file,
  write_cookie_file,
  readCookieFile,
  writeCookieFile,
} from './cookie-file.js';
export type { CookieMap, FlowCookieFileData } from './cookie-file.js';

export { load_browser_auth, loadBrowserAuth, CdpConnection } from './load-browser-cookies.js';
export type { FlowAuthResult } from './load-browser-cookies.js';

export { get_auth_token, getAuthToken } from './get-auth-token.js';
export type { AuthResult } from './get-auth-token.js';


path: scripts/flow-webapi/utils/load-browser-cookies.ts
content: import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';

import { logger } from './logger.js';
import { fetch_with_timeout, resolve_proxy, sleep } from './http.js';
import { type CookieMap, write_cookie_file } from './cookie-file.js';
import { resolveFlowWebCookiePath, resolveFlowWebDataDir } from './paths.js';

const DEFAULT_CDP_PORT = 9222;
const DEFAULT_WSL_CMD_EXE = '/mnt/c/Windows/System32/cmd.exe';
const DEFAULT_WSL_CHROME_EXE = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
const DEFAULT_WSL_USER_DATA_DIR = 'C:\\chrome-debug-openclaw';

type CdpSendOptions = { sessionId?: string; timeoutMs?: number };

export class CdpConnection {
  private ws: WebSocket;
  private nextId = 0;
  private pending = new Map<
    number,
    { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }
  >();

  private constructor(ws: WebSocket) {
    this.ws = ws;
    this.ws.addEventListener('message', (event) => {
      try {
        const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
        const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
        if (msg.id) {
          const p = this.pending.get(msg.id);
          if (p) {
            this.pending.delete(msg.id);
            if (p.timer) clearTimeout(p.timer);
            if (msg.error?.message) p.reject(new Error(msg.error.message));
            else p.resolve(msg.result);
          }
        }
      } catch {}
    });
    this.ws.addEventListener('close', () => {
      for (const [id, p] of this.pending.entries()) {
        this.pending.delete(id);
        if (p.timer) clearTimeout(p.timer);
        p.reject(new Error('CDP connection closed.'));
      }
    });
  }

  static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
    const ws = new WebSocket(url);
    await new Promise<void>((resolve, reject) => {
      const t = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
      ws.addEventListener('open', () => {
        clearTimeout(t);
        resolve();
      });
      ws.addEventListener('error', () => {
        clearTimeout(t);
        reject(new Error('CDP connection failed.'));
      });
    });
    return new CdpConnection(ws);
  }

  async send<T = unknown>(method: string, params?: Record<string, unknown>, opts?: CdpSendOptions): Promise<T> {
    const id = ++this.nextId;
    const msg: Record<string, unknown> = { id, method };
    if (params) msg.params = params;
    if (opts?.sessionId) msg.sessionId = opts.sessionId;

    const timeoutMs = opts?.timeoutMs ?? 15_000;
    const out = await new Promise<unknown>((resolve, reject) => {
      const t =
        timeoutMs > 0
          ? setTimeout(() => {
              this.pending.delete(id);
              reject(new Error(`CDP timeout: ${method}`));
            }, timeoutMs)
          : null;
      this.pending.set(id, { resolve, reject, timer: t });
      this.ws.send(JSON.stringify(msg));
    });
    return out as T;
  }

  close(): void {
    try {
      this.ws.close();
    } catch {}
  }
}

function get_cdp_port(): number {
  const envPort = parseInt(process.env.FLOW_WEB_DEBUG_PORT || process.env.AGENT_BROWSER_CDP_PORT || '', 10);
  if (envPort > 0) return envPort;
  return DEFAULT_CDP_PORT;
}

function is_wsl(): boolean {
  return !!process.env.WSL_DISTRO_NAME;
}

function parse_extra_args(raw: string | undefined): string[] {
  return (raw || '')
    .split(/[\r\n,]+/)
    .map((value) => value.trim())
    .filter(Boolean);
}

function resolve_local_chrome_path(): string | null {
  return process.env.FLOW_WEB_CHROME_PATH?.trim() || process.env.CHROME_PATH?.trim() || null;
}

function resolve_local_user_data_dir(): string {
  const override = process.env.FLOW_WEB_CHROME_PROFILE_DIR?.trim();
  if (override) return override;
  return path.join(resolveFlowWebDataDir(), 'chrome-debug-openclaw');
}

function start_windows_host_chrome(verbose: boolean): void {
  const cmdExe = process.env.AGENT_BROWSER_CMD_EXE_WSL?.trim() || DEFAULT_WSL_CMD_EXE;
  if (!fs.existsSync(cmdExe)) {
    throw new Error(`Windows cmd.exe bridge not found at: ${cmdExe}`);
  }

  const chromeExe =
    process.env.AGENT_BROWSER_CHROME_EXE_WIN?.trim() ||
    process.env.FLOW_WEB_CHROME_EXE_WIN?.trim() ||
    DEFAULT_WSL_CHROME_EXE;
  const userDataDir =
    process.env.AGENT_BROWSER_USER_DATA_DIR_WIN?.trim() ||
    process.env.FLOW_WEB_CHROME_USER_DATA_DIR_WIN?.trim() ||
    DEFAULT_WSL_USER_DATA_DIR;

  const args = [
    '/c',
    'start',
    '',
    chromeExe,
    `--remote-debugging-port=${get_cdp_port()}`,
    `--user-data-dir=${userDataDir}`,
  ];

  const proxy = resolve_proxy();
  if (proxy) args.push(`--proxy-server=${proxy}`);
  args.push(...parse_extra_args(process.env.FLOW_WEB_CHROME_EXTRA_ARGS || process.env.AGENT_BROWSER_CHROME_EXTRA_ARGS_WIN));

  if (verbose) logger.debug(`Starting host Chrome via cmd.exe with user data dir: ${userDataDir}`);
  const child = spawn(cmdExe, args, { detached: true, stdio: 'ignore' });
  child.unref();
}

function start_local_chrome(verbose: boolean): void {
  const chromePath = resolve_local_chrome_path();
  if (!chromePath) {
    throw new Error('FLOW_WEB_CHROME_PATH is required when not running under WSL.');
  }

  const args = [
    `--remote-debugging-port=${get_cdp_port()}`,
    `--user-data-dir=${resolve_local_user_data_dir()}`,
  ];

  const proxy = resolve_proxy();
  if (proxy) args.push(`--proxy-server=${proxy}`);
  args.push(...parse_extra_args(process.env.FLOW_WEB_CHROME_EXTRA_ARGS));

  if (verbose) logger.debug(`Starting local Chrome at: ${chromePath}`);
  const child = spawn(chromePath, args, { detached: true, stdio: 'ignore' });
  child.unref();
}

async function is_cdp_ready(port: number): Promise<boolean> {
  try {
    const res = await fetch_with_timeout(`http://127.0.0.1:${port}/json/version`, { timeout_ms: 2_000 });
    if (!res.ok) return false;
    const data = (await res.json()) as { webSocketDebuggerUrl?: string };
    return typeof data.webSocketDebuggerUrl === 'string' && data.webSocketDebuggerUrl.length > 0;
  } catch {
    return false;
  }
}

/** Ensure a Chrome CDP endpoint is available for UI automation. */
async function ensure_chrome_cdp(verbose: boolean): Promise<void> {
  const port = get_cdp_port();
  if (await is_cdp_ready(port)) {
    if (verbose) logger.debug(`Chrome CDP already ready on port ${port}`);
    return;
  }

  if (is_wsl()) {
    start_windows_host_chrome(verbose);
    return;
  }

  start_local_chrome(verbose);
}

async function wait_for_chrome_debug_port(port: number, timeoutMs: number): Promise<string> {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    try {
      const res = await fetch_with_timeout(`http://127.0.0.1:${port}/json/version`, { timeout_ms: 5_000 });
      if (!res.ok) throw new Error(`status=${res.status}`);
      const j = (await res.json()) as { webSocketDebuggerUrl?: string };
      if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
    } catch {}
    await sleep(200);
  }
  throw new Error(`Chrome debug port ${port} not ready after ${timeoutMs}ms`);
}

export type FlowAuthResult = {
  accessToken: string;
  cookies: CookieMap;
};

/**
 * Extract the OAuth access token from the Flow page.
 * Tries: __NEXT_DATA__ script tag → /fx/api/auth/session API
 */
async function extract_flow_token(
  cdp: CdpConnection,
  sessionId: string,
  verbose: boolean,
): Promise<string | null> {
  // Try __NEXT_DATA__ first
  try {
    const result = await cdp.send<{ result: { value?: string } }>(
      'Runtime.evaluate',
      {
        expression: `(() => {
          try {
            const el = document.getElementById('__NEXT_DATA__');
            if (el) {
              const d = JSON.parse(el.textContent);
              return d?.props?.pageProps?.session?.access_token || d?.props?.pageProps?.session?.accessToken || '';
            }
          } catch {}
          return '';
        })()`,
        returnByValue: true,
      },
      { sessionId, timeoutMs: 10_000 },
    );
    const [REDACTED_SECRET];
    if (typeof token === 'string' && token.startsWith('ya29.')) {
      if (verbose) logger.debug('Access token extracted from __NEXT_DATA__');
      return token;
    }
  } catch (e) {
    if (verbose) logger.debug(`__NEXT_DATA__ extraction failed: ${e instanceof Error ? e.message : String(e)}`);
  }

  // Fallback: call /fx/api/auth/session via page fetch
  try {
    const result = await cdp.send<{ result: { value?: string } }>(
      'Runtime.evaluate',
      {
        expression: `fetch('https://labs.google/fx/api/auth/session', { credentials: 'include' }).then(r => r.json()).then(d => d.access_token || d.accessToken || '')`,
        awaitPromise: true,
        returnByValue: true,
      },
      { sessionId, timeoutMs: 15_000 },
    );
    const [REDACTED_SECRET];
    if (typeof token === 'string' && token.startsWith('ya29.')) {
      if (verbose) logger.debug('Access token extracted from /fx/api/auth/session');
      return token;
    }
  } catch (e) {
    if (verbose) logger.debug(`/fx/api/auth/session fallback failed: ${e instanceof Error ? e.message : String(e)}`);
  }

  return null;
}

/**
 * Connect to a Chrome CDP instance, navigate to Flow, and extract auth.
 * The helper will start Chrome if the debug port is not already available.
 */
async function fetch_flow_auth_via_cdp(
  timeoutMs: number,
  verbose: boolean,
): Promise<FlowAuthResult> {
  const port = get_cdp_port();

  // Ensure Chrome is running
  await ensure_chrome_cdp(verbose);

  const wsUrl = await wait_for_chrome_debug_port(port, 30_000);
  const cdp = await CdpConnection.connect(wsUrl, 15_000);

  try {
    // Try to find an existing Flow tab first, else create a new one
    let targetId: string;
    let ownsTarget = false;

    try {
      const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
      const flowTab = targets.targetInfos.find(t => t.type === 'page' && t.url.includes('labs.google/fx'));
      if (flowTab) {
        targetId = flowTab.targetId;
        if (verbose) logger.debug(`Reusing existing Flow tab: ${flowTab.url}`);
      } else {
        const res = await cdp.send<{ targetId: string }>('Target.createTarget', {
          url: 'https://labs.google/fx/',
          newWindow: false,
        });
        targetId = res.targetId;
        ownsTarget = true;
      }
    } catch {
      const res = await cdp.send<{ targetId: string }>('Target.createTarget', {
        url: 'https://labs.google/fx/',
        newWindow: false,
      });
      targetId = res.targetId;
      ownsTarget = true;
    }

    const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true });
    await cdp.send('Network.enable', {}, { sessionId });
    await cdp.send('Runtime.enable', {}, { sessionId });

    if (verbose) {
      logger.info('Connected to Chrome CDP. Navigating to labs.google/fx/ for auth...');
    }

    const start = Date.now();

    while (Date.now() - start < timeoutMs) {
      await sleep(2000);

      // Grab cookies
      const { cookies } = await cdp.send<{ cookies: Array<{ name: string; value: string }> }>(
        'Network.getCookies',
        { urls: ['https://labs.google/', 'https://labs.google/fx/', 'https://accounts.google.com/'] },
        { sessionId, timeoutMs: 10_000 },
      );

      const m: CookieMap = {};
      for (const c of cookies) {
        if (c?.name && typeof c.value === 'string') m[c.name] = c.value;
      }

      // Try to extract access token
      const token = await extract_flow_token(cdp, sessionId, verbose);
      if (token) {
        // Only close the tab if we created it
        if (ownsTarget) {
          try {
            await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 });
          } catch {}
        }
        return { accessToken: token, cookies: m };
      }

      if (verbose) logger.debug('No valid access token yet, retrying...');
    }

    // Clean up tab on timeout (only if we created it)
    if (ownsTarget) {
      try {
        await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 });
      } catch {}
    }
    throw new Error('Timed out waiting for a valid Flow session with access token.');
  } finally {
    // Only close the WebSocket — do NOT close the browser
    cdp.close();
  }
}

export async function load_browser_auth(verbose: boolean = true): Promise<FlowAuthResult> {
  const result = await fetch_flow_auth_via_cdp(120_000, verbose);

  const filtered: CookieMap = {};
  for (const [k, v] of Object.entries(result.cookies)) {
    if (typeof v === 'string' && v.length > 0) filtered[k] = v;
  }

  await write_cookie_file(result.accessToken, filtered, resolveFlowWebCookiePath(), 'cdp');
  return { accessToken: result.accessToken, cookies: filtered };
}

export const loadBrowserAuth = load_browser_auth;
export { get_cdp_port, wait_for_chrome_debug_port, ensure_chrome_cdp };


path: scripts/flow-webapi/utils/logger.ts
content: export type LogLevel = 'TRACE' | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | number;

const lvl: Record<Exclude<LogLevel, number>, number> = {
  TRACE: 0,
  DEBUG: 1,
  INFO: 2,
  WARNING: 3,
  ERROR: 4,
  CRITICAL: 5,
};

let cur = lvl.INFO;

function toNum(level: LogLevel): number {
  if (typeof level === 'number') return level;
  return lvl[level] ?? lvl.INFO;
}

export function set_log_level(level: LogLevel): void {
  cur = toNum(level);
}

export const setLogLevel = set_log_level;

function emit(level: Exclude<LogLevel, number>, args: unknown[]): void {
  if (lvl[level] < cur) return;
  const prefix = `[flow_webapi] ${level}:`;

  // All log output goes to stderr to keep stdout clean for piped data (URLs, JSON)
  console.error(prefix, ...args);
}

export const logger = {
  trace: (...args: unknown[]) => emit('TRACE', args),
  debug: (...args: unknown[]) => emit('DEBUG', args),
  info: (...args: unknown[]) => emit('INFO', args),
  warning: (...args: unknown[]) => emit('WARNING', args),
  error: (...args: unknown[]) => emit('ERROR', args),
  success: (...args: unknown[]) => emit('INFO', args),
};


path: scripts/flow-webapi/utils/paths.ts
content: import { execSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';

const APP_DATA_DIR = 'baoyu-skills';
const FLOW_DATA_DIR = 'flow-web';
const COOKIE_FILE_NAME = 'cookies.json';
const PROFILE_DIR_NAME = 'chrome-profile';
const DEFAULT_WSL_DEBUG_PROFILE = '/mnt/c/chrome-debug-openclaw';

export function resolveUserDataRoot(): string {
  if (process.platform === 'win32') {
    return process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');
  }
  if (process.platform === 'darwin') {
    return path.join(os.homedir(), 'Library', 'Application Support');
  }
  return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share');
}

export function resolveFlowWebDataDir(): string {
  const override = process.env.FLOW_WEB_DATA_DIR?.trim();
  if (override) return path.resolve(override);
  return path.join(resolveUserDataRoot(), APP_DATA_DIR, FLOW_DATA_DIR);
}

export function resolveFlowWebCookiePath(): string {
  const override = process.env.FLOW_WEB_COOKIE_PATH?.trim();
  if (override) return path.resolve(override);
  return path.join(resolveFlowWebDataDir(), COOKIE_FILE_NAME);
}

function toWslPath(rawWindowsPath: string): string | null {
  try {
    return execSync(`wslpath -u "${rawWindowsPath}"`, { encoding: 'utf-8', timeout: 5000 }).trim() || null;
  } catch {
    return null;
  }
}

/** Default Chrome user data dir used for CDP launches. */
export function resolveFlowWebChromeProfileDir(): string {
  const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.FLOW_WEB_CHROME_PROFILE_DIR?.trim();
  if (override) return path.resolve(override);
  if (process.env.WSL_DISTRO_NAME) {
    const winOverride = process.env.AGENT_BROWSER_USER_DATA_DIR_WIN?.trim() || process.env.FLOW_WEB_CHROME_USER_DATA_DIR_WIN?.trim();
    if (winOverride) {
      const converted = toWslPath(winOverride);
      if (converted) return converted;
    }
    return DEFAULT_WSL_DEBUG_PROFILE;
  }
  return path.join(resolveUserDataRoot(), APP_DATA_DIR, PROFILE_DIR_NAME);
}


path: scripts/main.ts
content: import path from 'node:path';
import process from 'node:process';

import { FlowClient } from './flow-webapi/index.js';
import { set_log_level } from './flow-webapi/utils/logger.js';
import { resolveFlowWebChromeProfileDir, resolveFlowWebCookiePath } from './flow-webapi/utils/paths.js';
import { sleep } from './flow-webapi/utils/http.js';

type CliArgs = {
  prompts: string[];
  model: string;
  aspect: string;
  count: number;
  login: boolean;
  projectId: string | null;
  cookiePath: string | null;
  profileDir: string | null;
  verbose: boolean;
  help: boolean;
};

function formatScriptCommand(fallback: string): string {
  const raw = process.argv[1];
  const displayPath = raw
    ? (() => {
        const relative = path.relative(process.cwd(), raw);
        return relative && !relative.startsWith('..') ? relative : raw;
      })()
    : fallback;
  const quotedPath = displayPath.includes(' ')
    ? `"${displayPath.replace(/"/g, '\\"')}"`
    : displayPath;
  return `npx -y bun ${quotedPath}`;
}

function printUsage(cookiePath: string, profileDir: string): void {
  const cmd = formatScriptCommand('scripts/main.ts');
  console.log(`Usage:
  ${cmd} --prompt "A cute cat"
  ${cmd} "A sunset" --model NANO_BANANA_PRO --aspect 9:16 --count 2

Batch mode (multiple prompts in one project, 5s interval):
  ${cmd} --prompt "A cute cat" --prompt "A sunset" --prompt "A mountain"

Default behavior:
  Creates a new Flow project through the UI, then submits the prompt.
  Use --project-id only when you intentionally want to reuse an existing project.

Options:
  -p, --prompt <text>         Prompt text (repeat for batch mode)
  -m, --model <id>            NARWHAL (default), NANO_BANANA_PRO
  -a, --aspect <ratio>        16:9 (default), 9:16
  --count <n>                 Number of images: 1-4 (default: 2)
  --project-id <id>           Reuse existing project
  --login                     Only authenticate, then exit
  --verbose                   Enable debug logging
  --cookie-path <path>        Cookie file path (default: ${cookiePath})
  --profile-dir <path>        Chrome profile dir (default: ${profileDir})
  -h, --help                  Show help

Models:
  NARWHAL              Nano Banana 2 (default, fast)
  NANO_BANANA_PRO      Nano Banana Pro

Env overrides:
  FLOW_WEB_DATA_DIR, FLOW_WEB_COOKIE_PATH, FLOW_WEB_CHROME_PROFILE_DIR, FLOW_WEB_CHROME_PATH`);
}

function parseArgs(argv: string[]): CliArgs {
  const out: CliArgs = {
    prompts: [],
    model: 'NARWHAL',
    aspect: '16:9',
    count: 2,
    login: false,
    projectId: null,
    cookiePath: null,
    profileDir: null,
    verbose: false,
    help: false,
  };

  const positional: string[] = [];

  for (let i = 0; i < argv.length; i++) {
    const a = argv[i]!;

    if (a === '--help' || a === '-h') { out.help = true; continue; }
    if (a === '--login') { out.login = true; continue; }
    if (a === '--verbose') { out.verbose = true; continue; }

    if (a === '--prompt' || a === '-p') {
      const v = argv[++i];
      if (!v) throw new Error(`Missing value for ${a}`);
      out.prompts.push(v);
      continue;
    }

    if (a === '--model' || a === '-m') {
      const v = argv[++i];
      if (!v) throw new Error(`Missing value for ${a}`);
      out.model = v;
      continue;
    }

    if (a === '--aspect' || a === '-a') {
      const v = argv[++i];
      if (!v) throw new Error(`Missing value for ${a}`);
      out.aspect = v;
      continue;
    }

    if (a === '--count') {
      const v = argv[++i];
      if (!v) throw new Error('Missing value for --count');
      out.count = parseInt(v, 10);
      continue;
    }

    if (a === '--project-id') {
      const v = argv[++i];
      if (!v) throw new Error('Missing value for --project-id');
      out.projectId = v;
      continue;
    }

    if (a === '--cookie-path') {
      const v = argv[++i];
      if (!v) throw new Error('Missing value for --cookie-path');
      out.cookiePath = v;
      continue;
    }

    if (a === '--profile-dir') {
      const v = argv[++i];
      if (!v) throw new Error('Missing value for --profile-dir');
      out.profileDir = v;
      continue;
    }

    if (a.startsWith('-')) {
      throw new Error(`Unknown option: ${a}`);
    }

    positional.push(a);
  }

  if (out.prompts.length === 0 && positional.length > 0) {
    out.prompts.push(positional.join(' '));
  }

  return out;
}

async function readPromptFromStdin(): Promise<string | null> {
  if (process.stdin.isTTY) return null;
  try {
    const t = await Bun.stdin.text();
    const v = t.trim();
    return v.length > 0 ? v : null;
  } catch {
    return null;
  }
}

async function main(): Promise<void> {
  const args = parseArgs(process.argv.slice(2));

  if (args.cookiePath) process.env.FLOW_WEB_COOKIE_PATH = args.cookiePath;
  if (args.profileDir) process.env.FLOW_WEB_CHROME_PROFILE_DIR = args.profileDir;
  if (args.verbose) set_log_level('DEBUG');

  const cookiePath = resolveFlowWebCookiePath();
  const profileDir = resolveFlowWebChromeProfileDir();

  if (args.help) {
    printUsage(cookiePath, profileDir);
    return;
  }

  const client = new FlowClient({
    verbose: args.verbose,
  });

  try {
    // --login mode
    if (args.login) {
      process.env.FLOW_WEB_LOGIN = '1';
      await client.init();
      await client.close();
      console.error(`Authenticated. Cookie saved: ${cookiePath}`);
      return;
    }

    // Normal generation mode — need at least one prompt
    const prompts: string[] = [...args.prompts];
    if (prompts.length === 0) {
      const stdinPrompt = await readPromptFromStdin();
      if (stdinPrompt) prompts.push(stdinPrompt);
    }

    if (prompts.length === 0) {
      printUsage(cookiePath, profileDir);
      process.exitCode = 1;
      return;
    }

    await client.init();

    if (prompts.length === 1) {
      // Single prompt mode
      await client.generateImages({
        prompt: prompts[0]!,
        model: args.model,
        aspect: args.aspect,
        count: args.count,
        projectId: args.projectId ?? undefined,
      });
    } else {
      // Batch mode: create one project, submit prompts sequentially with 5s intervals
      let projectId = args.projectId ?? undefined;

      for (let i = 0; i < prompts.length; i++) {
        if (i > 0) {
          console.error(`Waiting 5s before next prompt (${i + 1}/${prompts.length})...`);
          await sleep(5000);
        }

        console.error(`Generating prompt ${i + 1}/${prompts.length}: "${prompts[i]!.slice(0, 60)}..."`);

        const result = await client.generateImages({
          prompt: prompts[i]!,
          model: args.model,
          aspect: args.aspect,
          count: args.count,
          projectId,
        });

        // Reuse the project created by the first generation
        if (!projectId) projectId = result.projectId;
      }
    }
  } finally {
    await client.close();
  }
}

main().then(() => process.exit(0)).catch((e) => {
  const msg = e instanceof Error ? e.message : String(e);
  console.error(msg);
  process.exit(1);
});


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)