Skill: seedling-you/yugoo-app-builder (version 1.0.4)

--- name: yugoo-app-builder description: Build full-stack web applications using 语构's chat-driven development platform. Manages conversations, sends development instructions, monitors build progress, extracts results (generated files, summaries, task status), publishes applications, and manages versions. metadata: slug: yugoo-app-builder display_name: 语构 App Builder homepage: https://creo4u.com env: - name: CREO4U_SKILL_API_KEY required: true description: API key for authenticating with the 语构 (Yugoo) platform. Use a least-privilege or revocable key. dependencies: - requests --- # Installation ## Prerequisites Before using this skill, ensure you have the `CREO4U_SKILL_API_KEY` environment variable set. ### Setup API Key **Option 1: Provide API Key during installation** ```bash export CREO4U_SKILL_API_KEY="your_api_key_here" ``` **Option 2: If not provided, the skill will prompt for it** - If `CREO4U_SKILL_API_KEY` is already set in your environment, the skill will use it directly - If not set, the skill will ask you to provide the API key ### After Installation Once installed, all CLI commands will automatically use the `CREO4U_SKILL_API_KEY` environment variable — no need to pass `--token` or any API key parameter. --- # 语构 App Builder 语构 is a **chat-driven full-stack application builder**. Users describe what they want in natural language and 语构 generates a **production-ready web application**, including frontend UI, backend services, database schema, and a live development environment. This skill enables AI agents to interact with the **语构 platform** to create, iterate, publish, and deploy applications. All platform operations must be executed through the CLI script: ```bash python scripts/yugoo_cli.py [global options] <command> [command options] ``` **IMPORTANT: Argument Order** — Global options (`--token`, `--base-url`, `--insecure`, `--timeout`) must appear **BEFORE** the subcommand. Placing them after the subcommand will cause a parse error. ```bash # ✅ Correct — global options before subcommand python scripts/yugoo_cli.py --token TOKEN create --name "My App" # ❌ Wrong — global options after subcommand python scripts/yugoo_cli.py create --token TOKEN --name "My App" ``` > **Note**: The `--insecure` flag disables TLS certificate verification. Only use it in controlled test environments — never for production or when transmitting real API keys. Do **not** call platform APIs directly. Always use the CLI commands provided by this skill. --- # When to Use This Skill Use this skill whenever the user wants to: * create a **website** or **web application** * build a **dashboard** or **admin panel** * create a **SaaS product** or **internal tool** * build a **full-stack application** with frontend + backend * build a **landing page** * create a **browser game** or **mini game** * iterate on an existing **语构 project** * publish or deploy an **语构 application** * check sandbox health or preview URLs * manage application **versions** * **copy** an existing application (own or from market) * **clone** an app from the **app market** * **switch** between application versions * **delete** or **finalize** a version Do **not** use this skill for unrelated programming tasks. --- # Routing Keywords Trigger this skill if the request includes concepts such as: * build a website * create a webpage * build a web app * create a SaaS * build a dashboard * create an admin panel * build an internal tool * create a landing page * build a browser game * create a mini game * generate a web product * make a snake game webpage * build a todo web app * create a blog site * publish this app * deploy this application * copy this app * clone an app from market * browse app market * switch to version * list versions * delete version * finalize version --- # Example Requests Examples that should route to this skill: * "Create a todo list web app" * "Build a personal blog website" * "Make a dashboard for sales analytics" * "Create a SaaS landing page" * "Build an admin panel" * "Write a snake game webpage" * "Create a browser game" * "Build a mini web game" * "Modify my 语构 project" * "Publish this 语构 app" * "Deploy my application" --- # Application Development Description Guidelines ## ✅ DO: Simple, Focused Requests ### For New Applications Describe the **core functionality** in 1-2 sentences: ``` "Build a todo app where users can add tasks, mark them complete, and delete them" "Create a dashboard to display sales data with charts and filters" "Build a landing page for a SaaS product with pricing section and contact form" ``` ### For Bug Fixes or Optimizations (1-2 changes per request) **✅ Correct - Single Focus:** ``` "Fix the submit button not responding on the contact form" "Optimize the loading speed of the data table" "Add error handling for invalid email input" "Fix the date picker not showing correct format" ``` **✅ Correct - Two Related Changes:** ``` "Fix the login button styling and add a loading indicator" "Optimize the database query and add caching for faster response" "Fix the mobile layout and adjust the navigation menu" ``` **❌ WRONG - Too Complex (DO NOT use):** ``` "Fix the login button, optimize the database, add user authentication, redesign the homepage, and deploy to production" ``` ## ⚠️ IMPORTANT: Complexity Limits When sending messages to the `chat` API: | Request Type | Max Items | |--------------|-----------| | Bug fixes | 1-2 related bugs | | Feature additions | 1-2 related features | | Optimizations | 1-2 related optimizations | | UI changes | 1-2 related components | **Rationale**: 语构 platform works best with focused, incremental changes. Complex multi-part requests may result in incomplete implementation or unexpected behavior. ## Iterative Development Pattern For complex requirements, break them into multiple chat messages: ```bash # Round 1: Build basic structure chat -c $CID -m "Build a todo app with add and list features" --watch # Round 2: Add one feature chat -c $CID -m "Add delete functionality to todo items" --watch # Round 3: Add another feature chat -c $CID -m "Add edit functionality and local storage" --watch # Round 4: Polish chat -c $CID -m "Improve the UI styling and add animations" --watch ``` --- # How to Describe Application Requirements ## ✅ Do: Keep It Simple Describe **what the application should do** in one or two sentences: ``` "Build a todo app where users can add tasks, mark them complete, and delete them" "Create a dashboard to display sales data with charts and filters" ``` ## ❌ Don't: Over-Specify Avoid: - Technology stack (e.g., "use React", "use FastAPI") - Performance requirements (e.g., "handle 1000 concurrent users") - Architecture constraints (e.g., "use microservices") - Detailed feature lists **For iterative modifications, keep requests brief — only describe the change needed, not the full application again.** **For bug fixes or optimizations, limit to 1-2 related changes per request.** See [Application Development Description Guidelines](#application-development-description-guidelines) for details. The platform handles technical decisions automatically. --- # Model Selection The `chat` command accepts an optional `--model-choice` parameter that selects which model the **Task agent** uses for this message. The Pilot agent always uses the platform default and is **not** affected by this flag. ## Accepted values | Value | Meaning | |-------|---------| | *(omitted)* or `auto` | Smart routing — platform picks the best model | | `economy` | Preset: cheaper / faster models | | `advance` | Preset: balanced cost vs. capability | | `expert` | Preset: highest-capability models | | `<model-id>` | A concrete model id from `python scripts/yugoo_cli.py models` (e.g. `claude-sonnet-4-6`) | Format constraint: `^[A-Za-z0-9._\-]+$`, max length 80. ## Typical usage ```bash # Discover available choices python scripts/yugoo_cli.py models # Pick a preset python scripts/yugoo_cli.py chat -c $CID -m "Build a SaaS landing page" --model-choice expert --watch # Pin a concrete model id python scripts/yugoo_cli.py chat -c $CID -m "Add e2e tests" --model-choice claude-sonnet-4-6 --watch ``` ## When to set it explicitly - The user asks for a specific model ("use Sonnet", "switch to the cheaper model"). - You're running a high-stakes / complex change and want the `expert` preset. - You're doing many trivial iterations and want the `economy` preset to save credits. For most cases, leave it unset (smart routing). --- # Stateless Execution Model The CLI script is **stateless**. It does not store workflow state between calls. Application workflow state is maintained by the 语构 platform and must be tracked via: * `conversation_id` — the unique session identifier * `message_id` — identifies a specific message/generation run Agents must pass the appropriate identifiers when continuing conversations or checking results. --- # Application Lifecycle 语构 applications follow a standard lifecycle: ## 1. Initial Creation For a new application: 1. Start with a `chat` request describing the product 2. The platform generates the application automatically 3. Application is available in the development environment ## 2. Multi-Round Modification After an application has been created: * Continue using `chat` with the same `conversation_id` * Chat messages modify the existing application * No additional generation step required ## 3. Publishing Publishing deploys the application to production: * Use `publish` command to trigger deployment * Use `publish --wait` to auto-poll until deployment completes * After successful publish, the app is live at the preview URL --- # Application URLs 语构 provides URLs during the lifecycle: ## Development/Preview URL After the application is created, access it at: ``` https://preview-{conversation_id}.creo4u.com/ ``` This URL is for: * viewing the application * testing features * development iteration ## Production URL After publishing succeeds, the application is accessible at: ``` https://preview-{conversation_id}.creo4u.com/ ``` This is the **public production URL** of the deployed application. Share this URL only after publishing completes successfully. --- # Standard Workflow ## Create New Application ``` create → chat --watch → publish --wait → urls ``` ## Iterate on Existing Application ``` chat --watch → result → chat --watch → result → ... ``` ## Publish Application ``` publish --wait → urls ``` ## Copy Own Application ``` copy → chat --watch → result → publish --wait ``` ## Clone from App Market ``` market-list → clone-market → chat --watch → result → publish --wait ``` ## Version Management ``` versions → restore-version → chat --watch → finalize-version ``` ## Build with Attachments ``` upload --file → chat --attachment OSS_PATH --watch → publish --wait → urls ``` ## Build from Local File ``` chat --file /path/to/requirements.txt --watch → result → publish --wait → urls ``` --- # Complete Workflow Example ```bash # Step 1: Create a conversation CONV=$(python scripts/yugoo_cli.py create --name "Todo App") CID=$(echo $CONV | python -c "import sys,json; print(json.load(sys.stdin)['conversation_id'])") # Step 2: Send development instructions and watch progress # The poll_completed snapshot contains cost/time in assistant messages python scripts/yugoo_cli.py chat \ -c $CID \ -m "Build a todo app with add, complete, and delete features" \ --watch # Step 3: Publish the application (auto-wait for completion) python scripts/yugoo_cli.py publish -c $CID --wait # Step 4: Get the production URL python scripts/yugoo_cli.py urls -c $CID ``` --- # Available Commands **Note**: All commands automatically use the `CREO4U_SKILL_API_KEY` environment variable for authentication. ## create Create a new conversation. ```bash python scripts/yugoo_cli.py create [--name NAME] ``` | Parameter | Default | Description | |-----------|---------|-------------| | `--name` | `New Conversation` | Conversation name | **Output**: JSON with `conversation_id`, `name`, `status`, `created_at`. --- ## upload Upload a local file as a chat attachment. The file is uploaded to OSS and the returned `oss_path` can be passed to `chat --attachment`. ```bash python scripts/yugoo_cli.py upload --file /path/to/file ``` | Parameter | Required | Description | |-----------|----------|-------------| | `--file` | Yes | Local file path to upload (max 50MB) | **Output**: JSON with `oss_path`, `filename`, `file_md5`, `size_bytes`. ```json {"oss_path": "chat/attachments/123/abc123def456/document.pdf", "filename": "document.pdf", "file_md5": "abc123def456...", "size_bytes": 1048576} ``` **Example — Upload then chat with attachment:** ```bash # Step 1: Upload the file UPLOAD=$(python scripts/yugoo_cli.py upload --file design.png) OSS_PATH=$(echo $UPLOAD | python -c "import sys,json; print(json.load(sys.stdin)['oss_path'])") # Step 2: Send message with attachment python scripts/yugoo_cli.py chat -c $CID -m "参考这个设计图来实现页面" --attachment "$OSS_PATH" --watch ``` --- ## chat Send a message and optionally watch for completion via polling. This is the primary workflow command. ```bash python scripts/yugoo_cli.py chat -c CID -m "message" [--watch] [--attachment OSS_PATH] python scripts/yugoo_cli.py chat -c CID -f /path/to/file [--watch] ``` | Parameter | Required | Description | |-----------|----------|-------------| | `-c`, `--conversation-id` | No | Conversation ID (omit to auto-create) | | `-m`, `--content` | No* | Message content (use `-` to read from stdin) | | `-f`, `--file` | No* | Read message content from a local file path | | `-w`, `--watch` | No | Poll for completion after sending message (15s interval) | | `--agent-name` | No | Specific agent to handle the message | | `--context` | No | JSON string for additional context | | `--attachment` | No | OSS path of uploaded attachment (repeatable for multiple files) | | `--model-choice` | No | Model selection for the Task agent. Accepts a preset (`auto` \| `economy` \| `advance` \| `expert`) or a concrete model id from the `models` command. Omit (or pass `auto`) to use smart routing. Only affects the Task agent; the Pilot agent always uses the platform default. | *Either `--content` or `--file` is required. `--file` takes priority if both are provided. **Output without `--watch`**: Single JSON line with `message_id`, `conversation_id`. **Output with `--watch`**: Polling mode with completion status ```json {"event": "message_accepted", "message_id": "...", "conversation_id": "..."} {"event": "poll", "iteration": 1, "state": "pending"} {"event": "poll", "iteration": 2, "state": "running"} {"event": "poll_completed", "iteration": 3, "state": "completed", "snapshot": {...}} ``` **Important**: - Use `--watch` for most cases — it sends the message AND polls for completion in one command. - Use `--file` to send a local file's content as the message — useful for long requirement documents or pre-written prompts. - Use `--content -` to read long prompts from stdin. - Use `upload` command first to get `oss_path`, then pass it via `--attachment`. - Use `--model-choice` to override the default model routing (see [Model Selection](#model-selection)). **Example — pick a model preset:** ```bash # Use the high-capability "expert" preset for the Task agent python scripts/yugoo_cli.py chat -c $CID -m "Refactor the auth flow" --model-choice expert --watch # Use a concrete model id (look it up via the `models` command first) python scripts/yugoo_cli.py chat -c $CID -m "Add tests" --model-choice claude-sonnet-4-6 --watch ``` --- ## models List available model presets and concrete model ids for use with `chat --model-choice`. This is a public endpoint and does not consume credit. ```bash python scripts/yugoo_cli.py models ``` **Output**: ```json { "presets": [ {"id": "auto", "display_name": "Auto", "description": "Smart routing", "is_default": true, "first_model": "...", "fallback_chain": ["..."]}, {"id": "economy", "display_name": "Economy", "description": "Cheaper, faster models", "first_model": "...", "fallback_chain": ["..."]}, {"id": "advance", "display_name": "Advance", "description": "Balanced", "first_model": "...", "fallback_chain": ["..."]}, {"id": "expert", "display_name": "Expert", "description": "Highest capability", "first_model": "...", "fallback_chain": ["..."]} ], "models": [ {"id": "claude-sonnet-4-6", "display_name": "Claude Sonnet 4.6", "series": "claude", "price_tier": "advance", "is_latest": true, "enabled": true, "context_window": 200000} ], "default_choice": "auto" } ``` **When to use**: - Before the first chat in a session, if you want a non-default model. - When the user explicitly asks "which models can I use?" / "switch to a cheaper model" / etc. - Pass either a preset `id` (e.g. `expert`) or a concrete model `id` (e.g. `claude-sonnet-4-6`) to `chat --model-choice`. --- ## publish Trigger deployment to production. ```bash python scripts/yugoo_cli.py publish -c CID [--wait] ``` | Parameter | Required | Description | |-----------|----------|-------------| | `-c`, `--conversation-id` | Yes | Conversation ID to publish | | `-w`, `--wait` | No | Auto-poll publish status until published or failed | **Examples:** **1. Publish and manually check status:** ```bash python scripts/yugoo_cli.py publish -c $CID ``` **2. Publish and auto-wait for completion (recommended):** ```bash python scripts/yugoo_cli.py publish -c $CID --wait ``` **Important:** - Application must be created before publishing - After successful publish, the app is live at the preview URL - Use `--wait` flag to avoid manual polling --- ## publish-status Check the status of a deployment. ```bash python scripts/yugoo_cli.py publish-status -c CID ``` | Parameter | Required | Description | |-----------|----------|-------------| | `-c`, `--conversation-id` | Yes | Conversation ID | **Output**: JSON with `status`: `created`, `publishing`, `published`, or `failed` **Usage Pattern:** ```bash # Publish and auto-wait for completion (recommended) python scripts/yugoo_cli.py publish -c $CID --wait # Or check status manually python scripts/yugoo_cli.py publish-status -c $CID ``` **Tip:** Use `publish --wait` to avoid manual polling. --- ## messages Get the full conversation snapshot (all messages and state). ```bash python scripts/yugoo_cli.py messages -c CID ``` **Output**: Complete snapshot JSON with all runs, messages, panels, and metadata. --- ## result Extract structured development results from the conversation snapshot. This is the recommended way to get results after a `chat --watch` completes. ```bash python scripts/yugoo_cli.py result -c CID [--message-id MID] ``` | Parameter | Required | Description | |-----------|----------|-------------| | `-c`, `--conversation-id` | Yes | Conversation ID | | `--message-id` | No | Specific message run (default: latest) | **Output**: ```json { "conversation_id": "...", "message_id": "...", "status": "completed", "summary": "Built a full-stack todo application...", "files_generated": ["src/App.tsx", "backend/main.py"], "files_modified": ["package.json"], "assistant_messages": ["..."], "tasks": [ {"task_id": "...", "title": "Generate frontend code", "status": "completed", "type": "streaming_multi_file"} ], "error": null, "token_usage": {"prompt_tokens": 1234, "completion_tokens": 5678, "total_tokens": 6912}, "credit_usage": 100, "started_at": 1705771805000, "completed_at": 1705771900000 } ``` --- ## stop Stop message generation in progress. ```bash python scripts/yugoo_cli.py stop -c CID --message-id MID ``` --- ## start-runtime Start or restart the conversation runtime (container environment). ```bash python scripts/yugoo_cli.py start-runtime -c CID ``` Use this after a container has been reclaimed due to inactivity. --- ## list List conversations with pagination and filtering. ```bash python scripts/yugoo_cli.py list [--limit N] [--offset N] [--keyword KW] [--sort-by FIELD] [--sort-order asc|desc] ``` --- ## get Get conversation details. ```bash python scripts/yugoo_cli.py get -c CID ``` --- ## delete Terminate a conversation. ```bash python scripts/yugoo_cli.py delete -c CID ``` --- ## health Check sandbox services health (frontend, backend, sandbox interface). ```bash python scripts/yugoo_cli.py health -c CID ``` **Output**: ```json { "frontend": {"ready": true, "status_code": 200, "message": "Frontend service is ready"}, "backend": {"ready": true, "status_code": 200, "message": "Backend service is ready"}, "sandbox": {"ready": true, "status_code": 200, "message": "Sandbox service is ready"}, "all_ready": true } ``` --- ## urls Get sandbox, preview, and backend URLs for the development environment. ```bash python scripts/yugoo_cli.py urls -c CID ``` **Output**: ```json {"sandbox_url": "https://sandbox-xxx.domain.com", "preview_url": "https://preview-xxx.domain.com", "backend_url": "https://backend-xxx.domain.com"} ``` --- ## download Download workspace as a zip file. ```bash python scripts/yugoo_cli.py download -c CID ``` **Output**: JSON with `download_url`, `filename`, `size_mb`, `md5`. --- ## versions List all saved versions for a conversation. ```bash python scripts/yugoo_cli.py versions -c CID [--sort-order asc|desc] ``` | Parameter | Required | Description | |-----------|----------|-------------| | `-c`, `--conversation-id` | Yes | Conversation ID | | `--sort-order` | No | Sort direction: `asc` (oldest first) or `desc` (newest first, default) | **Output**: JSON with `conversation_id`, `current_version`, `versions` array (each with `version`, `timestamp`, `is_wip`, `is_published`, `description`, `from_version`), and `total` count. **Example**: ```bash python scripts/yugoo_cli.py versions -c $CID # Output: {"conversation_id": "...", "current_version": 3, "versions": [...], "total": 3} ``` --- ## current-version Get the current active version number and its metadata. ```bash python scripts/yugoo_cli.py current-version -c CID ``` | Parameter | Required | Description | |-----------|----------|-------------| | `-c`, `--conversation-id` | Yes | Conversation ID | **Output**: JSON with `conversation_id`, `current_version`, `is_wip`, `from_version`, `description`. --- ## restore-version Switch workspace to a specific version. This changes the active codebase and restarts the sandbox. ```bash python scripts/yugoo_cli.py restore-version -c CID --version N ``` | Parameter | Required | Description | |-----------|----------|-------------| | `-c`, `--conversation-id` | Yes | Conversation ID | | `--version` | Yes | Version number to switch to | **Output**: JSON with `success`, `version`, `mode` (`tag` or `wip`), `message`. **Important**: - Switching versions restarts the sandbox runtime automatically. - Cannot switch versions while another operation is in progress (returns 409). - After switching, use `chat` to continue development from that version. --- ## copy Create a deep copy of an existing conversation (own application). ```bash python scripts/yugoo_cli.py copy -c CID [--new-name NAME] [--wait] ``` | Parameter | Required | Description | |-----------|----------|-------------| | `-c`, `--conversation-id` | Yes | Source conversation ID | | `--new-name` | No | Name for the copy (default: "{original name}（副本）") | | `-w`, `--wait` | No | Auto-poll copy status until workspace is ready (5s interval, max 10 minutes) | **Output**: JSON with `conversation_id`, `name`, `status`, `created_at` of the new conversation. **Important**: - Creates a completely independent copy with new conversation ID - Workspace files are copied in the background (1-30 seconds) - The new conversation is immediately usable for chat commands - Use `--wait` to block until workspace files are fully copied **Example**: ```bash # Copy an existing app (without waiting) NEW=$(python scripts/yugoo_cli.py copy -c $CID --new-name "My App V2") NEW_CID=$(echo $NEW | python -c "import sys,json; print(json.load(sys.stdin)['conversation_id'])") # Copy and wait for workspace to be ready NEW=$(python scripts/yugoo_cli.py copy -c $CID --new-name "My App V2" --wait) NEW_CID=$(echo $NEW | python -c "import sys,json; print(json.load(sys.stdin)['conversation_id'])") # Iterate on the copy python scripts/yugoo_cli.py chat -c $NEW_CID -m "Add dark mode support" --watch ``` --- ## market-list Browse and search the app market. ```bash python scripts/yugoo_cli.py market-list [--limit N] [--offset N] [--keyword KW] [--category-id ID] [--sort-by FIELD] ``` | Parameter | Required | Description | |-----------|----------|-------------| | `--limit` | No | Max results (default: 20) | | `--offset` | No | Pagination offset (default: 0) | | `--keyword` | No | Search keyword | | `--category-id` | No | Filter by category ID | | `--sort-by` | No | Sort field (e.g. `view_count`, `clone_count`, `created_at`) | **Output**: JSON with `items` array containing market item details (item_id, title, description, clone_count, view_count, etc.) and pagination info. **Example**: ```bash # Search for todo apps python scripts/yugoo_cli.py market-list --keyword "todo" # Browse most popular apps python scripts/yugoo_cli.py market-list --sort-by view_count --limit 10 ``` --- ## clone-market Clone an app from the app market into your conversation list. ```bash python scripts/yugoo_cli.py clone-market --item-id ITEM_ID ``` | Parameter | Required | Description | |-----------|----------|-------------| | `--item-id` | Yes | Market item ID to clone | **Output**: JSON with `conversation_id`, `cloned_code`, `cloned_prd`, `cloned_history`, `message_count`. **Important**: - The cloned app is created as a new conversation you own - What gets cloned depends on the original app's clone permission settings: - CODE: workspace source files - PRD: design documents - HISTORY: conversation messages - Workspace files are copied in the background **Example - Clone from Market and Iterate**: ```bash # Step 1: Browse market python scripts/yugoo_cli.py market-list --keyword "dashboard" # Step 2: Clone the app (use item_id from market-list result) CLONE=$(python scripts/yugoo_cli.py clone-market --item-id $ITEM_ID) CID=$(echo $CLONE | python -c "import sys,json; print(json.load(sys.stdin)['conversation_id'])") # Step 3: Customize the cloned app python scripts/yugoo_cli.py chat -c $CID -m "Change the color scheme to dark blue" --watch # Step 4: Publish your customized version python scripts/yugoo_cli.py publish -c $CID --wait ``` --- ## delete-version Delete a WIP (work-in-progress) version and revert to the previous version. ```bash python scripts/yugoo_cli.py delete-version -c CID --version N ``` | Parameter | Required | Description | |-----------|----------|-------------| | `-c`, `--conversation-id` | Yes | Conversation ID | | `--version` | Yes | Version number to delete | **Output**: JSON with `success`, `version`, `reverted_to_version`, `message`. **Important**: - Only WIP versions can be deleted. Finalized versions cannot be deleted. - The workspace automatically reverts to the previous version after deletion. - Cannot delete a version while another operation is in progress (returns 409). --- ## finalize-version Finalize (lock) the current WIP version, making it a permanent checkpoint. ```bash python scripts/yugoo_cli.py finalize-version -c CID ``` | Parameter | Required | Description | |-----------|----------|-------------| | `-c`, `--conversation-id` | Yes | Conversation ID | **Output**: JSON with `success`, `version`, `from_version`, `message`. **Important**: - Only works when the workspace is on a WIP branch. - After finalization, the version becomes a permanent tag that cannot be deleted. - Use this to create stable checkpoints before making major changes. --- ## update-version-desc Update the description of a specific version. ```bash python scripts/yugoo_cli.py update-version-desc -c CID --version N --description "description text" ``` | Parameter | Required | Description | |-----------|----------|-------------| | `-c`, `--conversation-id` | Yes | Conversation ID | | `--version` | Yes | Version number | | `--description` | Yes | New description text | **Output**: JSON with `success`, `version`, `description`, `message`. --- ## rename Rename a conversation. ```bash python scripts/yugoo_cli.py rename -c CID --name "New Name" ``` --- ## spending Get message-level spending (cost and time) for a conversation. Supports filtering by `message_id` to query a single message's cost and duration. ```bash python scripts/yugoo_cli.py spending -c CID [--message-id MID] [--limit N] [--offset N] ``` | Parameter | Required | Description | |-----------|----------|-------------| | `-c`, `--conversation-id` | Yes | Conversation ID | | `--message-id` | No | Filter by specific message ID | | `--limit` | No | Max results (default: 50) | | `--offset` | No | Pagination offset (default: 0) | **Output**: ```json { "session_id": "202501201430-a1b2c3d4", "app_name": "My Web App", "total_cost": "1500", "total": 1, "items": [ { "message_id": "msg-001", "message_preview": "Build a login page...", "total_cost": "300", "record_count": 3, "first_billing_at": "2025-01-20T14:30:00", "last_billing_at": "2025-01-20T14:35:00", "started_at": "2025-01-20T14:30:00", "finished_at": "2025-01-20T14:35:00", "duration_seconds": 300.0 } ], "limit": 20, "offset": 0 } ``` **Fields**: - `total_cost`: Total credits consumed for the entire conversation (integer string) - `items[].total_cost`: Credits consumed per message/development round - `items[].started_at` / `finished_at`: Start and end timestamps of the development round - `items[].duration_seconds`: Duration of the development round in seconds - `items[].first_billing_at` / `last_billing_at`: Time range of billing for each round --- # Output Conventions - **stdout**: JSON lines (one JSON object per line). Machine-readable. - **stderr**: Error messages and metadata. - **Exit code 0**: Success. - **Exit code 1**: Error (details on stderr as JSON). Every stdout line is a complete, self-contained JSON object. This enables piping to `jq` for filtering and line-by-line processing by AI agents. --- # Polling Mode The `chat` and `publish` commands with `--watch` use polling to monitor progress: - **Interval**: Fixed 15 seconds between polls - **Single request timeout**: 10 minutes (40 iterations per request) - **Maximum total timeout**: 1 hour (up to 6 retries) Polling stops automatically when the run reaches `completed` or `failed` state. **Recommendation**: Use `--wait` flag for publish command to auto-poll status. ## poll Resume polling for a message that is still in progress (after a `poll_timeout`). ```bash python scripts/yugoo_cli.py poll -c CID --message-id MID ``` | Parameter | Required | Description | |-----------|----------|-------------| | `-c`, `--conversation-id` | Yes | Conversation ID | | `--message-id` | Yes | Message ID to poll | **Output**: Same as `chat --watch` — emits `poll` events and ends with `poll_completed` or `poll_timeout`. ## CRITICAL: Polling Completion Rules **You MUST follow these rules strictly when using `chat --watch`:** 1. **Single request timeout is 10 minutes**: Each `chat --watch` or `poll` command polls for up to **10 minutes**. If the development is not done, the CLI emits a `poll_timeout` event and exits. This is normal — development can take longer than 10 minutes. 2. **On `poll_timeout`, you MUST retry using the `poll` command**: When you receive a `poll_timeout` event, immediately run the `poll` command with the same `conversation_id` and `message_id` to resume monitoring. **Do NOT** treat `poll_timeout` as a failure or stop. 3. **Maximum 6 retries (1 hour total)**: You may retry the `poll` command up to **6 times** (including the initial `chat --watch`). This ensures a maximum total timeout of 1 hour (6 × 10 min). If all 6 attempts time out, then treat it as a genuine timeout and report to the user. **Retry flow:** ``` chat --watch (attempt 1, up to 10 min) → poll_timeout? → poll (attempt 2, up to 10 min) → poll_timeout? → poll (attempt 3, up to 10 min) → poll_timeout? → poll (attempt 4, up to 10 min) → poll_timeout? → poll (attempt 5, up to 10 min) → poll_timeout? → poll (attempt 6, up to 10 min) → poll_timeout? → report timeout to user ``` **Example:** ```bash # Initial request python scripts/yugoo_cli.py chat -c $CID -m "Build a complex app" --watch # If poll_timeout, resume with poll: python scripts/yugoo_cli.py poll -c $CID --message-id $MID # Repeat poll up to 5 more times if needed ``` 4. **Wait for ALL tasks to complete**: The polling only ends with `poll_completed` when **every task and phase** in the development pipeline has finished (status = `completed` or `failed`). Do NOT consider the operation done just because an assistant message appeared or a partial result was returned. 5. **Do NOT stop polling on intermediate messages**: During development, the platform may return intermediate status messages such as: - "服务正在启动中，预计还需要几分钟时间。前端服务启动后即可访问！" - "正在生成代码..." - "正在部署..." - Any message indicating services are still starting or building These are **progress updates, NOT completion signals**. You MUST continue waiting for the `poll_completed` event. **Never** interpret these messages as the final result. 6. **Only `poll_completed` means truly done**: - `poll_completed` → Development finished. Proceed to `result` and `spending`. - `poll_timeout` → Not finished yet. Retry with `poll` command (up to 6 total attempts). --- # Error Handling All HTTP errors produce JSON on stderr: ```json {"error": {"error": "CONVERSATION_NOT_FOUND", "message": "Conversation not found"}, "status_code": 404} ``` Common error codes: | Status | Error | Meaning | |--------|-------|---------| | 402 | `INSUFFICIENT_CREDIT` | User has insufficient credit | | 404 | `CONVERSATION_NOT_FOUND` | Conversation does not exist | | 409 | `CONVERSATION_IN_PROGRESS` | Another message is being processed | | 409 | `WIP_VERSION_CONFLICT` | Version conflict during message send | | 409 | `PUBLISH_IN_PROGRESS` | Another publish is being processed | | 501 | `NOT_IMPLEMENTED` | Feature not available in current mode | If the conversation is locked (409), wait a few seconds and retry. --- # Application Copy & Clone Workflow ## Copy Your Own Application Use `copy` to create an independent copy of your existing application, then iterate on it: ```bash # Copy the app NEW=$(python scripts/yugoo_cli.py copy -c $CID --new-name "Todo App V2") NEW_CID=$(echo $NEW | python -c "import sys,json; print(json.load(sys.stdin)['conversation_id'])") # Develop on the copy python scripts/yugoo_cli.py chat -c $NEW_CID -m "Add user authentication" --watch # Publish the new version python scripts/yugoo_cli.py publish -c $NEW_CID --wait ``` ## Clone from App Market Use `market-list` to find apps, then `clone-market` to clone into your workspace: ```bash # Browse the market python scripts/yugoo_cli.py market-list --keyword "ecommerce" --limit 5 # Clone an app (use item_id from market-list output) CLONE=$(python scripts/yugoo_cli.py clone-market --item-id $ITEM_ID) CID=$(echo $CLONE | python -c "import sys,json; print(json.load(sys.stdin)['conversation_id'])") # Customize the cloned app python scripts/yugoo_cli.py chat -c $CID -m "Replace product catalog with my inventory" --watch # Get results and publish python scripts/yugoo_cli.py result -c $CID python scripts/yugoo_cli.py publish -c $CID --wait python scripts/yugoo_cli.py urls -c $CID ``` --- # Version Management Workflow Versions allow you to create checkpoints, switch between states, and safely experiment. ## View and Switch Versions ```bash # List all versions python scripts/yugoo_cli.py versions -c $CID # Check current version python scripts/yugoo_cli.py current-version -c $CID # Switch to an earlier version python scripts/yugoo_cli.py restore-version -c $CID --version 2 ``` ## Create a Checkpoint (Finalize) After development, finalize the current WIP version to create a permanent checkpoint: ```bash # Develop features python scripts/yugoo_cli.py chat -c $CID -m "Add payment integration" --watch # Lock the version as a stable checkpoint python scripts/yugoo_cli.py finalize-version -c $CID # Add a description to the version python scripts/yugoo_cli.py update-version-desc -c $CID --version 3 --description "Added payment integration with Stripe" ``` ## Undo Changes (Delete WIP Version) If a development round didn't go well, delete the WIP version to revert: ```bash # Check current version (e.g., version 4, WIP) python scripts/yugoo_cli.py current-version -c $CID # Delete the WIP version and revert to previous python scripts/yugoo_cli.py delete-version -c $CID --version 4 # Now back at version 3 — try again python scripts/yugoo_cli.py chat -c $CID -m "Add payment integration using a different approach" --watch ``` --- # Error Handling Guidance Agents should handle the following situations: * publish requested before application has been created * missing `conversation_id` * interrupted polling loop * failed deployment status * incorrect or missing parameter values If workflow state is unclear, inspect the conversation or use `result` command before taking the next action. **Pro Tips:** - Always check command `--help` when unsure about parameters - Use `--wait` flag with `publish` to simplify deployment - Save `conversation_id` from `create` response - After creation, use `chat` directly for modifications --- # Application Sharing Rule (IMPORTANT) - You must share your application **only using the production preview URL** (after it has been published). Production preview URL format: ``` https://preview-{conversation_id}.creo4u.com/ ``` - If the application has **not been published**, you must publish it before sharing with others. Example publish command: ```bash python scripts/yugoo_cli.py publish -c $CID --wait ``` --- # Tips - Use `--watch` with `chat` for the common case — it sends the message and polls for completion. - Use `--wait` with `publish` to auto-poll deployment status. - Use `result` after completion to get a clean summary without parsing poll events. - Use `--file` with `chat` to send a local file's content as the message, useful for long requirement documents or pre-written prompts. - The `--content -` flag reads message content from stdin, useful for long prompts. - `conversation_id` is abbreviated as `-c` in all commands for convenience. - Always check `result` output for `status: "failed"` and `error` field to detect failures. - Use `health` to verify the sandbox is ready before sending development instructions. - Use `start-runtime` to restart a container that was reclaimed due to inactivity. --- # Development Completion Best Practice **After an application is built, do NOT manually inspect the generated code files.** After `chat --watch` (or `poll`) completes with `poll_completed`: 1. Query **Development Cost** and **Development Time** using the `spending` command with `--message-id`: ```bash python scripts/yugoo_cli.py spending -c "$CID" --message-id "$MID" ``` Extract from the response: - **Development Cost**: `items[0].total_cost` (Credits) - **Development Time**: `items[0].duration_seconds` (seconds) 2. Publish and get URLs: ```bash python scripts/yugoo_cli.py publish -c "$CID" --wait python scripts/yugoo_cli.py urls -c "$CID" ``` Then respond with the following **fixed format** (do NOT change field names or order): ``` **Application Summary**: [one-line description of what was built and key features] **Preview URL**: https://preview-{conversation_id}.creo4u.com/ **Publish URL**: [url field from publish command output] **Publish Status**: [published / failed] **Development Cost**: [N] Credits **Development Time**: [N]s ``` **IMPORTANT**: Always publish after development completes. Never skip any field. Always include both Preview URL and Publish URL.

path: scripts/yugoo_cli.py
content: #!/usr/bin/env python3
"""
Yugoo Platform CLI Client

Stateless command-line interface for the Yugoo (语构) chat API.
Designed for AI agent integrations (skills) — all output is JSON lines on stdout.

Usage:
    python yugoo_cli.py [--token <jwt>] [--base-url URL] [--insecure] <command> [options]

IMPORTANT: Global options (--token, --base-url, --insecure, --timeout) must appear
BEFORE the subcommand. Placing them after the subcommand will cause an error.

Environment variables:
    YUGOO_BASE_URL       - Platform base URL (alternative to --base-url)
    CREO4U_SKILL_API_KEY - API key for authentication (alternative to --token)
"""

import argparse
import hashlib
import json
import os
import sys
import time
from typing import Any, Dict, Iterator, List, NoReturn, Optional

try:
    import requests
except ImportError:
    print(
        "Error: 'requests' package not installed. Run: pip install requests",
        file=sys.stderr,
    )
    sys.exit(1)

DEFAULT_BASE_URL = "https://creo4u.com/autoagent/api"
VERSION = "1.0.0"


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _headers(token: str) -> Dict[str, str]:
    return {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
        "Accept": "application/json",
        "User-Agent": f"yugoo-cli/{VERSION}",
    }


def _sse_headers(token: str) -> Dict[str, str]:
    return {
        "Authorization": f"Bearer {token}",
        "Accept": "text/event-stream",
        "Cache-Control": "no-cache",
        "Connection": "keep-alive",
        "User-Agent": f"yugoo-cli/{VERSION}",
    }


def _emit(obj: Any) -> None:
    """Write a single JSON line to stdout."""
    print(json.dumps(obj, ensure_ascii=False, default=str), flush=True)


def _log(msg: str) -> None:
    """Write a message to stderr."""
    print(msg, file=sys.stderr, flush=True)


def _die(msg: str, code: int = 1) -> NoReturn:
    """Print error to stderr and exit."""
    print(json.dumps({"error": msg}, ensure_ascii=False), file=sys.stderr)
    sys.exit(code)


def _raise_for_api_error(resp: "requests.Response") -> Any:
    """Check HTTP status and return parsed JSON body. Raises on error."""
    try:
        resp.raise_for_status()
    except requests.exceptions.HTTPError:
        try:
            body = resp.json()
        except Exception:
            body = {"raw": resp.text}
        error_info = {
            "error": body.get("detail", body) if isinstance(body, dict) else body,
            "status_code": resp.status_code,
        }
        _log(json.dumps(error_info, ensure_ascii=False, default=str))
        sys.exit(1)
    return resp.json()


def _get(cfg: dict, path: str, params: Optional[dict] = None) -> Any:
    """HTTP GET helper."""
    url = f"{cfg['base_url']}{path}"
    try:
        resp = requests.get(url, headers=_headers(cfg["token"]), params=params, timeout=cfg["timeout"], verify=cfg["verify"])
    except requests.exceptions.ConnectionError:
        _die(f"Connection failed: {url}")
    except requests.exceptions.Timeout:
        _die(f"Request timeout: {url}")
    return _raise_for_api_error(resp)


def _get_text(cfg: dict, path: str) -> str:
    """HTTP GET helper that returns plain text."""
    url = f"{cfg['base_url']}{path}"
    try:
        resp = requests.get(url, headers=_headers(cfg["token"]), timeout=cfg["timeout"], verify=cfg["verify"])
    except requests.exceptions.ConnectionError:
        _die(f"Connection failed: {url}")
    except requests.exceptions.Timeout:
        _die(f"Request timeout: {url}")
    try:
        resp.raise_for_status()
    except requests.exceptions.HTTPError:
        try:
            body = resp.json()
        except Exception:
            body = {"raw": resp.text}
        _log(json.dumps({"error": body, "status_code": resp.status_code}, ensure_ascii=False, default=str))
        sys.exit(1)
    return resp.text


def _post(cfg: dict, path: str, body: Optional[dict] = None) -> Any:
    """HTTP POST helper."""
    url = f"{cfg['base_url']}{path}"
    try:
        resp = requests.post(url, headers=_headers(cfg["token"]), json=body or {}, timeout=cfg["timeout"], verify=cfg["verify"])
    except requests.exceptions.ConnectionError:
        _die(f"Connection failed: {url}")
    except requests.exceptions.Timeout:
        _die(f"Request timeout: {url}")
    return _raise_for_api_error(resp)


def _delete(cfg: dict, path: str) -> Any:
    """HTTP DELETE helper."""
    url = f"{cfg['base_url']}{path}"
    try:
        resp = requests.delete(url, headers=_headers(cfg["token"]), timeout=cfg["timeout"], verify=cfg["verify"])
    except requests.exceptions.ConnectionError:
        _die(f"Connection failed: {url}")
    except requests.exceptions.Timeout:
        _die(f"Request timeout: {url}")
    return _raise_for_api_error(resp)


def _patch(cfg: dict, path: str, body: Optional[dict] = None) -> Any:
    """HTTP PATCH helper."""
    url = f"{cfg['base_url']}{path}"
    try:
        resp = requests.patch(url, headers=_headers(cfg["token"]), json=body or {}, timeout=cfg["timeout"], verify=cfg["verify"])
    except requests.exceptions.ConnectionError:
        _die(f"Connection failed: {url}")
    except requests.exceptions.Timeout:
        _die(f"Request timeout: {url}")
    return _raise_for_api_error(resp)


# ---------------------------------------------------------------------------
# SSE Streaming
# ---------------------------------------------------------------------------


def stream_sse(
    cfg: dict,
    path: str,
    last_event_id: Optional[str] = None,
    snapshot_interval: Optional[int] = None,
) -> Iterator[Dict[str, Any]]:
    """
    Connect to SSE endpoint and yield parsed events.

    Each yielded dict has:
      {"event": str, "sequence": str|None, "data": dict|str}

    Terminal events: "completed", "error"
    """
    url = f"{cfg['base_url']}{path}"
    headers = _sse_headers(cfg["token"])
    if last_event_id is not None:
        headers["Last-Event-ID"] = str(last_event_id)

    params = {}
    if snapshot_interval is not None:
        params["snapshot_interval"] = snapshot_interval

    try:
        resp = requests.get(
            url,
            headers=headers,
            params=params or None,
            stream=True,
            timeout=(cfg["timeout"], None),  # connect timeout, no read timeout
            verify=cfg["verify"],
        )
    except requests.exceptions.ConnectionError:
        _die(f"SSE connection failed: {url}")
    except requests.exceptions.Timeout:
        _die(f"SSE connection timeout: {url}")

    try:
        resp.raise_for_status()
    except requests.exceptions.HTTPError:
        try:
            body = resp.json()
        except Exception:
            body = {"raw": resp.text}
        _log(json.dumps({"error": body, "status_code": resp.status_code}, ensure_ascii=False, default=str))
        sys.exit(1)

    # Parse SSE frames
    # SSE field prefixes
    SSE_DATA_PREFIX = "data" + ":"  # "" - constructed to avoid rendering issues
    SSE_ID_PREFIX = "id:"
    SSE_EVENT_PREFIX = "event:"

    current_id = None
    current_event = None
    current_data_buf = ""

    for line in resp.iter_lines(decode_unicode=True):
        if line is None:
            continue

        if line == "":
            # Empty line = end of SSE frame
            if current_event or current_data_buf:
                parsed_payload: Any = {}
                if current_data_buf:
                    try:
                        parsed_payload = json.loads(current_data_buf)
                    except json.JSONDecodeError:
                        parsed_payload = current_data_buf

                event_obj = {
                    "event": current_event or "message",
                    "sequence": current_id,
                    "data": parsed_payload,
                }
                yield event_obj

                # Check for terminal events
                if current_event in ("completed", "error"):
                    return

            current_id = None
            current_event = None
            current_data_buf = ""

        elif line.startswith(SSE_ID_PREFIX):
            current_id = line[len(SSE_ID_PREFIX):].strip()
        elif line.startswith(SSE_EVENT_PREFIX):
            current_event = line[len(SSE_EVENT_PREFIX):].strip()
        elif line.startswith(SSE_DATA_PREFIX):
            data_part = line[len(SSE_DATA_PREFIX):].strip()
            if current_data_buf:
                current_data_buf += data_part
            else:
                current_data_buf = data_part


# ---------------------------------------------------------------------------
# Snapshot Result Extraction
# ---------------------------------------------------------------------------


def extract_result(snapshot_data: dict, message_id: Optional[str] = None) -> dict:
    """
    Extract structured development results from a CopilotInterface snapshot.

    Walks through runs[message_id].messages to find assistant responses
    with files_generated, files_modified, summary, and extracts task status
    from OperationLogPanel.
    """
    # Unwrap Result envelope, then unwrap Snapshot.data to get CopilotInterface
    outer = snapshot_data.get("data", snapshot_data) if isinstance(snapshot_data, dict) else snapshot_data
    inner = outer.get("data", outer) if isinstance(outer, dict) else outer
    runs = inner.get("runs", {}) if isinstance(inner, dict) else {}

    if not runs:
        return {"error": "No runs found in snapshot"}

    # Determine which run(s) to extract from
    if message_id and message_id in runs:
        target_runs = {message_id: runs[message_id]}
    else:
        # Use the latest run (last key)
        last_key = list(runs.keys())[-1]
        target_runs = {last_key: runs[last_key]}

    results: List[dict] = []
    for mid, run in target_runs.items():
        files_generated: List[str] = []
        files_modified: List[str] = []
        summaries: List[str] = []
        assistant_msgs: List[str] = []

        for msg in run.get("messages", []):
            if msg.get("role") != "assistant":
                continue
            content = msg.get("content")
            if content:
                assistant_msgs.append(content)
            if msg.get("files_generated"):
                files_generated.extend(msg["files_generated"])
            if msg.get("files_modified"):
                files_modified.extend(msg["files_modified"])
            if msg.get("summary"):
                summaries.append(msg["summary"])

        # Extract tasks from OperationLogPanel
        tasks: List[dict] = []
        panels = run.get("panels", {})
        op_log = panels.get("OperationLogPanel", {})
        for phase in op_log.get("phases", []):
            payload = phase.get("payload", {})
            for task in payload.get("tasks", []):
                task_info = {
                    "task_id": task.get("task_id", ""),
                    "title": task.get("title", ""),
                    "status": task.get("status", ""),
                    "type": task.get("type", ""),
                }
                task_info["sub_tasks"] = []
                for st in task.get("sub_tasks", []):
                    task_info["sub_tasks"].append({
                        "sub_task_id": st.get("sub_task_id", ""),
                        "file_name": st.get("file_name", st.get("task_name", "")),
                        "status": st.get("status", ""),
                    })
                for f in task.get("files", []):
                    task_info["sub_tasks"].append({
                        "sub_task_id": f.get("file_id", ""),
                        "file_name": f.get("file_path", f.get("file_name", "")),
                        "status": f.get("status", ""),
                    })
                if not task_info["sub_tasks"]:
                    del task_info["sub_tasks"]
                tasks.append(task_info)

            # AutoCopilot phases have goal instead of tasks
            goal = payload.get("goal")
            if goal:
                tasks.append({
                    "task_id": goal.get("plan_id", ""),
                    "title": goal.get("goal", ""),
                    "status": goal.get("status", ""),
                    "type": "auto_copilot_goal",
                })

        result = {
            "conversation_id": outer.get("conversation_id", "") if isinstance(outer, dict) else "",
            "message_id": mid,
            "status": run.get("state", "unknown"),
            "summary": summaries[-1] if summaries else None,
            "files_generated": sorted(set(files_generated)),
            "files_modified": sorted(set(files_modified)),
            "assistant_messages": assistant_msgs,
            "tasks": tasks,
            "error": run.get("error"),
            "token_usage": run.get("metadata", {}).get("token_usage"),
            "credit_usage": run.get("metadata", {}).get("credit_usage"),
            "started_at": run.get("started_at"),
            "completed_at": run.get("completed_at"),
        }
        results.append(result)

    if len(results) == 1:
        return results[0]
    return {"results": results}


# ---------------------------------------------------------------------------
# Polling Mode
# ---------------------------------------------------------------------------


def poll_until_complete(
    cfg: dict,
    conversation_id: str,
    message_id: str,
    interval: float = 15.0,
    max_iterations: int = 40,
) -> Iterator[Dict[str, Any]]:
    """
    Poll the snapshot endpoint until the run for message_id reaches terminal state.
    Yields structured poll events.

    Args:
        interval: Polling interval in seconds (default: 15s)
        max_iterations: Max polling iterations (default: 40 = 10 minutes)
    """
    path = f"/chat/v3/conversations/{conversation_id}/messages"
    iteration = 0

    # Terminal states that indicate the run is finished
    TERMINAL_STATES = {"completed", "failed", "cancelled", "interrupted"}

    while True:
        iteration += 1
        snapshot = _get(cfg, path)
        # Unwrap Result envelope, then unwrap Snapshot.data to get CopilotInterface
        outer = snapshot.get("data", snapshot) if isinstance(snapshot, dict) else {}
        data = outer.get("data", outer) if isinstance(outer, dict) else {}
        runs = data.get("runs", {}) if isinstance(data, dict) else {}
        run = runs.get(message_id, {}) if isinstance(runs, dict) else {}
        state = run.get("state", "pending") if isinstance(run, dict) else "pending"

        # Check panels for task completion status
        panels = run.get("panels", {})
        op_log = panels.get("OperationLogPanel", {})
        phases = op_log.get("phases", [])

        # Determine if all phases/tasks are actually done
        has_executing_phase = False
        has_pending_phase = False

        for phase in phases:
            payload = phase.get("payload", {})
            phase_status = payload.get("status", "")
            if phase_status == "executing":
                has_executing_phase = True
            if phase_status == "pending":
                has_pending_phase = True

        # Check goal status if present (auto_copilot)
        has_executing_goal = False
        for phase in phases:
            payload = phase.get("payload", {})
            goal = payload.get("goal")
            if goal and goal.get("status") == "executing":
                has_executing_goal = True

        # Only consider complete if state is terminal AND no executing phases/goals
        if state in TERMINAL_STATES:
            if has_executing_phase or has_executing_goal:
                # State says complete but tasks still running - keep polling
                _emit({"event": "poll", "iteration": iteration, "state": "executing"})
            else:
                yield {"event": "poll_completed", "iteration": iteration, "state": state, "snapshot": snapshot}
                return
        elif has_executing_phase or has_executing_goal:
            # Still executing
            _emit({"event": "poll", "iteration": iteration, "state": "executing"})
        else:
            _emit({"event": "poll", "iteration": iteration, "state": state})

        if max_iterations > 0 and iteration >= max_iterations:
            yield {"event": "poll_timeout", "iteration": iteration, "state": state, "snapshot": snapshot}
            return

        time.sleep(interval)


# ---------------------------------------------------------------------------
# Command Handlers
# ---------------------------------------------------------------------------


def cmd_create(args: argparse.Namespace, cfg: dict) -> None:
    """Create a new conversation."""
    body = {"name": args.name}
    result = _post(cfg, "/chat/v3/conversations", body)
    _emit(result)


def _file_md5(filepath: str) -> str:
    """Calculate MD5 hash of a file."""
    h = hashlib.md5()
    with open(filepath, "rb") as f:
        for chunk in iter(lambda: f.read(8192), b""):
            h.update(chunk)
    return h.hexdigest()


def cmd_upload(args: argparse.Namespace, cfg: dict) -> None:
    """Upload a local file as a chat attachment."""
    filepath = args.file
    if not os.path.isfile(filepath):
        _die(f"File not found: {filepath}")

    filename = os.path.basename(filepath)
    file_size = os.path.getsize(filepath)
    _log(f"Calculating MD5 for {filename} ({file_size} bytes)...")
    file_md5 = _file_md5(filepath)

    # Step 1: Get upload credentials
    _log("Getting upload credentials...")
    creds = _get(cfg, "/chat/v3/attachments/upload-url", params={
        "fileMd5": file_md5,
        "filename": filename,
    })

    oss_path = creds.get("ossPath") or creds.get("oss_path", "")
    upload_host = creds.get("host", "")
    if upload_host.startswith("//"):
        upload_host = "https:" + upload_host

    # Step 2: Upload file to OSS
    _log(f"Uploading to OSS: {oss_path}...")
    with open(filepath, "rb") as f:
        upload_data = {
            "key": oss_path,
            "OSSAccessKeyId": creds.get("access_id", ""),
            "policy": creds.get("policy", ""),
            "signature": creds.get("signature", ""),
            "success_action_status": "200",
        }
        upload_files = {"file": (filename, f)}
        try:
            resp = requests.post(
                upload_host, data=upload_data, files=upload_files,
                timeout=300,
            )
        except requests.exceptions.ConnectionError:
            _die(f"OSS upload connection failed: {upload_host}")
        except requests.exceptions.Timeout:
            _die("OSS upload timed out")

    if resp.status_code not in (200, 204):
        _die(f"OSS upload failed with status {resp.status_code}: {resp.text}")

    _log("Upload complete.")
    _emit({
        "oss_path": oss_path,
        "filename": filename,
        "file_md5": file_md5,
        "size_bytes": file_size,
    })


def cmd_models(args: argparse.Namespace, cfg: dict) -> None:
    """List available presets and models for --model-choice."""
    result = _get(cfg, "/models")
    # Normalize Result envelope to its payload when present
    if isinstance(result, dict) and "data" in result and isinstance(result["data"], dict):
        result = result["data"]
    _emit(result)


def cmd_chat(args: argparse.Namespace, cfg: dict) -> None:
    """Send a message and optionally watch the response stream."""
    # Resolve content: --file takes priority, then --content, then error
    if args.file:
        try:
            with open(args.file, "r", encoding="utf-8") as fh:
                content = fh.read()
        except FileNotFoundError:
            _die(f"File not found: {args.file}")
        except OSError as exc:
            _die(f"Cannot read file {args.file}: {exc}")
    elif args.content:
        content = args.content
        if content == "-":
            content = sys.stdin.read()
    else:
        _die("Either --content (-m) or --file (-f) is required")

    body: Dict[str, Any] = {"content": content}
    if args.conversation_id:
        body["conversation_id"] = args.conversation_id
    if args.agent_name:
        body["agent_name"] = args.agent_name
    if args.model_choice:
        body["model_choice"] = args.model_choice
    if args.context:
        try:
            body["context"] = json.loads(args.context)
        except json.JSONDecodeError:
            _die("--context must be valid JSON")

    # Merge attachments into context
    if args.attachment:
        ctx = body.get("context") or {}
        ctx["attachments"] = args.attachment
        body["context"] = ctx

    # Step 1: Send message
    result = _post(cfg, "/chat/v3/messages", body)

    # Check for business-level errors (API returns 200 but with error in body)
    if isinstance(result, dict):
        if result.get("code") == -1 or result.get("success") is False:
            error_msg = result.get("message", "Unknown error")
            _die(error_msg)

    data = result.get("data", result) if isinstance(result, dict) else result
    message_id = data.get("message_id", "")
    conversation_id = data.get("conversation_id", "")

    # Emit accepted event
    _emit({
        "event": "message_accepted",
        "message_id": message_id,
        "conversation_id": conversation_id,
    })

    # Step 2: Optionally watch
    if args.watch:
        # Polling mode with fixed 5s interval
        _log("Polling every 15.0s...")
        for event in poll_until_complete(cfg, conversation_id, message_id, interval=15.0):
            _emit(event)


def cmd_poll(args: argparse.Namespace, cfg: dict) -> None:
    """Resume polling for a message that is still in progress."""
    conversation_id = args.conversation_id
    message_id = args.message_id

    _log("Resuming poll every 15.0s...")
    for event in poll_until_complete(cfg, conversation_id, message_id, interval=15.0):
        _emit(event)


def cmd_stream(args: argparse.Namespace, cfg: dict) -> None:
    """Watch SSE stream for a specific message."""
    path = f"/chat/v3/conversations/{args.conversation_id}/messages/{args.message_id}/stream"
    for event in stream_sse(
        cfg,
        path,
        last_event_id=args.last_event_id,
        snapshot_interval=args.snapshot_interval,
    ):
        _emit(event)


def cmd_messages(args: argparse.Namespace, cfg: dict) -> None:
    """Get the full snapshot for a conversation."""
    path = f"/chat/v3/conversations/{args.conversation_id}/messages"
    result = _get(cfg, path)
    _emit(result)


def cmd_result(args: argparse.Namespace, cfg: dict) -> None:
    """Extract structured development results from snapshot."""
    path = f"/chat/v3/conversations/{args.conversation_id}/messages"
    snapshot = _get(cfg, path)
    extracted = extract_result(snapshot, message_id=args.message_id)
    _emit(extracted)


def cmd_stop(args: argparse.Namespace, cfg: dict) -> None:
    """Stop message generation."""
    path = f"/chat/v3/conversations/{args.conversation_id}/messages/{args.message_id}/stop"
    result = _post(cfg, path)
    _emit(result)


def cmd_start_runtime(args: argparse.Namespace, cfg: dict) -> None:
    """Start or restart conversation runtime."""
    path = f"/chat/v3/conversations/{args.conversation_id}/start"
    result = _post(cfg, path)
    _emit(result)


def cmd_list(args: argparse.Namespace, cfg: dict) -> None:
    """List conversations."""
    params: Dict[str, Any] = {
        "limit": args.limit,
        "offset": args.offset,
    }
    if args.keyword:
        params["keyword"] = args.keyword
    if args.sort_by:
        params["sort_by"] = args.sort_by
    if args.sort_order:
        params["sort_order"] = args.sort_order
    if args.is_published is not None:
        params["is_published"] = args.is_published

    result = _get(cfg, "/chat/v3/conversations", params=params)
    _emit(result)


def cmd_get(args: argparse.Namespace, cfg: dict) -> None:
    """Get conversation details."""
    path = f"/chat/v3/conversations/{args.conversation_id}"
    result = _get(cfg, path)
    _emit(result)


def cmd_delete(args: argparse.Namespace, cfg: dict) -> None:
    """Terminate a conversation."""
    path = f"/chat/v3/conversations/{args.conversation_id}"
    result = _delete(cfg, path)
    _emit(result)


def cmd_health(args: argparse.Namespace, cfg: dict) -> None:
    """Check sandbox services health."""
    path = f"/chat/v3/conversations/{args.conversation_id}/health"
    result = _get(cfg, path)
    _emit(result)


def cmd_urls(args: argparse.Namespace, cfg: dict) -> None:
    """Get sandbox, preview, and backend URLs."""
    cid = args.conversation_id
    urls: Dict[str, Optional[str]] = {}

    for name, endpoint in [
        ("sandbox_url", f"/chat/v3/conversations/{cid}/sandbox"),
        ("preview_url", f"/chat/v3/conversations/{cid}/preview"),
        ("backend_url", f"/chat/v3/conversations/{cid}/backend"),
    ]:
        try:
            urls[name] = _get_text(cfg, endpoint)
        except SystemExit:
            urls[name] = None

    _emit(urls)


def cmd_download(args: argparse.Namespace, cfg: dict) -> None:
    """Download workspace."""
    path = f"/chat/v3/conversations/{args.conversation_id}/download"
    result = _get(cfg, path)
    _emit(result)


def cmd_versions(args: argparse.Namespace, cfg: dict) -> None:
    """List versions."""
    path = f"/chat/v3/conversations/{args.conversation_id}/versions"
    params = {}
    if args.sort_order:
        params["sort_order"] = args.sort_order
    result = _get(cfg, path, params=params or None)
    _emit(result)


def cmd_current_version(args: argparse.Namespace, cfg: dict) -> None:
    """Get current version info."""
    path = f"/chat/v3/conversations/{args.conversation_id}/versions/current"
    result = _get(cfg, path)
    _emit(result)


def cmd_restore_version(args: argparse.Namespace, cfg: dict) -> None:
    """Restore to a specific version."""
    path = f"/chat/v3/conversations/{args.conversation_id}/versions/{args.version}/restore"
    result = _post(cfg, path)
    _emit(result)


def cmd_copy(args: argparse.Namespace, cfg: dict) -> None:
    """Copy a conversation."""
    path = f"/chat/v3/conversations/{args.conversation_id}/copy"
    body: Dict[str, Any] = {}
    if args.new_name:
        body["new_name"] = args.new_name
    result = _post(cfg, path, body)
    _emit(result)

    # Optionally poll status
    if args.wait:
        data = result.get("data", result) if isinstance(result, dict) else result
        new_conversation_id = data.get("conversation_id", "")
        if new_conversation_id:
            _log("Polling copy status...")
            poll_copy_status(cfg, new_conversation_id)


def poll_copy_status(cfg: dict, conversation_id: str, interval: float = 5.0, max_iterations: int = 120) -> None:
    """Poll copy status until completion.
    
    Copy operation completes when workspace files are ready.
    Status transitions: copying -> active (ready)
    """
    path = f"/chat/v3/conversations/{conversation_id}"
    
    TERMINAL_STATUSES = {"active", "failed"}
    
    for iteration in range(1, max_iterations + 1):
        result = _get(cfg, path)
        data = result.get("data", result) if isinstance(result, dict) else result
        status = data.get("status", "unknown")

        _emit({"iteration": iteration, "status": status, "conversation_id": conversation_id})

        if status in TERMINAL_STATUSES:
            if status == "failed":
                _log(f"Copy failed: {data.get('error', 'Unknown error')}")
                sys.exit(1)
            _log("Copy completed successfully!")
            return
        
        _log(f"Copy in progress... (iteration {iteration})")
        time.sleep(interval)
    
    _die("Copy status polling timeout")


def cmd_publish(args: argparse.Namespace, cfg: dict) -> None:
    """Trigger deployment to production."""
    path = "/publish/"
    body: Dict[str, Any] = {
        "session_id": args.conversation_id,
        "republish": True,
    }
    if args.env:
        body["env"] = args.env

    result = _post(cfg, path, body)

    # Check for business-level errors
    if isinstance(result, dict):
        if result.get("code") == -1 or result.get("success") is False:
            error_msg = result.get("message", "Unknown error")
            _die(error_msg)

    data = result.get("data", result) if isinstance(result, dict) else result
    release_id = data.get("publish_id", data.get("id", ""))
    _emit(result)

    # Optionally poll status
    if args.wait:
        _log("Polling publish status...")
        poll_publish_status(cfg, args.conversation_id, release_id)


def cmd_publish_status(args: argparse.Namespace, cfg: dict) -> None:
    """Check the status of a deployment."""
    path = f"/publish/session/{args.conversation_id}/status"
    result = _get(cfg, path)
    _emit(result)


def poll_publish_status(cfg: dict, conversation_id: str, release_id: str, interval: float = 15.0, max_iterations: int = 40) -> None:
    """Poll publish status until completion."""
    path = f"/publish/session/{conversation_id}/status"

    TERMINAL_STATES = {"published", "published", "failed", "COMPLETED", "completed", "failed"}

    for iteration in range(1, max_iterations + 1):
        result = _get(cfg, path)
        data = result.get("data", result) if isinstance(result, dict) else result
        status = data.get("status", data.get("state", "UNKNOWN"))

        _emit({"iteration": iteration, "status": status})

        if status in TERMINAL_STATES:
            if status == "failed" or status == "FAILED":
                _log(f"Publish failed: {data.get('message', data.get('error', 'Unknown error'))}")
                sys.exit(1)
            _log("Publish completed successfully!")
            return

        time.sleep(interval)

    _die("Publish status polling timeout")


def cmd_rename(args: argparse.Namespace, cfg: dict) -> None:
    """Rename a conversation."""
    path = f"/chat/v3/conversations/{args.conversation_id}/name"
    body = {"name": args.name}
    result = _patch(cfg, path, body)
    _emit(result)


def cmd_market_list(args: argparse.Namespace, cfg: dict) -> None:
    """List app market items."""
    params: Dict[str, Any] = {
        "limit": args.limit,
        "offset": args.offset,
    }
    if args.keyword:
        params["keyword"] = args.keyword
    if args.category_id is not None:
        params["category_id"] = args.category_id
    if args.sort_by:
        params["sort_by"] = args.sort_by
    result = _get(cfg, "/app-market/items", params=params)
    _emit(result)


def cmd_clone_market(args: argparse.Namespace, cfg: dict) -> None:
    """Clone an app from the market."""
    path = f"/app-market/items/{args.item_id}/clone"
    result = _post(cfg, path)
    _emit(result)


def cmd_delete_version(args: argparse.Namespace, cfg: dict) -> None:
    """Delete a WIP version."""
    path = f"/chat/v3/conversations/{args.conversation_id}/versions/{args.version}"
    result = _delete(cfg, path)
    _emit(result)


def cmd_finalize_version(args: argparse.Namespace, cfg: dict) -> None:
    """Finalize current WIP version."""
    path = f"/chat/v3/conversations/{args.conversation_id}/versions/finalize"
    result = _post(cfg, path)
    _emit(result)


def cmd_update_version_desc(args: argparse.Namespace, cfg: dict) -> None:
    """Update version description."""
    path = f"/chat/v3/conversations/{args.conversation_id}/versions/{args.version}/description"
    body = {"description": args.description}
    result = _patch(cfg, path, body)
    _emit(result)


def cmd_spending(args: argparse.Namespace, cfg: dict) -> None:
    """Get message-level spending for a conversation."""
    path = f"/credit/spending/sessions/{args.conversation_id}/messages"
    params: Dict[str, Any] = {
        "limit": args.limit,
        "offset": args.offset,
    }
    if args.message_id:
        params["message_id"] = args.message_id
    result = _get(cfg, path, params=params)
    _emit(result)


# ---------------------------------------------------------------------------
# CLI Parser
# ---------------------------------------------------------------------------


def build_parser() -> argparse.ArgumentParser:
    """Construct the argument parser with all subcommands."""
    parser = argparse.ArgumentParser(
        prog="yugoo",
        description="Yugoo Platform CLI — stateless chat API client for agent integration",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Global options (--token, --base-url, --insecure, --timeout) must appear\n"
            "BEFORE the subcommand. Example:\n"
            "  yugoo --token TOKEN --insecure create --name \"My App\"   (correct)\n"
            "  yugoo create --token TOKEN --name \"My App\"              (WRONG)\n\n"
            "Environment variables:\n"
            "  YUGOO_BASE_URL   API base URL (default: %(default_url)s)\n"
            "  CREO4U_SKILL_API_KEY API key for authentication"
        ) % {"default_url": DEFAULT_BASE_URL},
    )
    parser.add_argument(
        "--token",
        default=None,
        help="JWT Bearer token (default: CREO4U_SKILL_API_KEY env var)",
    )
    parser.add_argument(
        "--base-url",
        default=os.environ.get("YUGOO_BASE_URL", DEFAULT_BASE_URL),
        help=f"API base URL (default: {DEFAULT_BASE_URL})",
    )
    parser.add_argument(
        "--timeout",
        type=int,
        default=int(os.environ.get("AUTOAGENT_TIMEOUT", "30")),
        help="HTTP request timeout in seconds (default: 30)",
    )
    parser.add_argument(
        "--insecure", "-k",
        action="store_true",
        default=os.environ.get("AUTOAGENT_INSECURE", "").lower() in ("1", "true", "yes"),
        help="Disable SSL certificate verification",
    )

    sub = parser.add_subparsers(dest="command", required=True)

    # -- create --
    p = sub.add_parser("create", help="Create a new conversation")
    p.add_argument("--name", default="New Conversation", help="Conversation name (default: New Conversation)")

    # -- models --
    p = sub.add_parser("models", help="List available model presets and concrete model ids for --model-choice")

    # -- upload --
    p = sub.add_parser("upload", help="Upload a local file as a chat attachment")
    p.add_argument("--file", required=True, help="Local file path to upload")

    # -- chat --
    p = sub.add_parser("chat", help="Send a message and optionally watch the response stream")
    p.add_argument("-c", "--conversation-id", default=None, help="Conversation ID (omit to auto-create)")
    p.add_argument("-m", "--content", required=False, default=None, help="Message content (use '-' to read from stdin)")
    p.add_argument("-f", "--file", default=None, help="Read message content from a local file path")
    p.add_argument("-w", "--watch", action="store_true", help="Watch for completion via polling (5s interval)")
    p.add_argument("--agent-name", default=None, help="Specific agent to handle the message")
    p.add_argument("--context", default=None, help="JSON string for additional context")
    p.add_argument("--attachment", action="append", default=None, help="OSS path of uploaded attachment (repeatable)")
    p.add_argument(
        "--model-choice",
        default=None,
        help=(
            "Model choice: preset id ('auto' | 'economy' | 'advance' | 'expert') "
            "or a concrete model id from 'models' command. Default is smart routing. "
            "Only affects the Task agent."
        ),
    )

    # -- poll --
    p = sub.add_parser("poll", help="Resume polling for a message still in progress")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")
    p.add_argument("--message-id", required=True, help="Message ID")

    # -- stream --
    p = sub.add_parser("stream", help="Watch SSE stream for a specific message")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")
    p.add_argument("--message-id", required=True, help="Message ID")
    p.add_argument("--last-event-id", default=None, help="Resume from this event ID")
    p.add_argument("--snapshot-interval", type=int, default=None, help="Request snapshot every N events")

    # -- messages --
    p = sub.add_parser("messages", help="Get full conversation snapshot")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")

    # -- result --
    p = sub.add_parser("result", help="Extract structured development results from snapshot")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")
    p.add_argument("--message-id", default=None, help="Specific message run to extract (default: latest)")

    # -- stop --
    p = sub.add_parser("stop", help="Stop message generation")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")
    p.add_argument("--message-id", required=True, help="Message ID")

    # -- start-runtime --
    p = sub.add_parser("start-runtime", help="Start or restart conversation runtime")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")

    # -- list --
    p = sub.add_parser("list", help="List conversations")
    p.add_argument("--limit", type=int, default=50, help="Max results (default: 50)")
    p.add_argument("--offset", type=int, default=0, help="Pagination offset (default: 0)")
    p.add_argument("--keyword", default=None, help="Search keyword")
    p.add_argument("--sort-by", default="updated_at", help="Sort field (default: updated_at)")
    p.add_argument("--sort-order", default="desc", choices=["asc", "desc"], help="Sort direction (default: desc)")
    p.add_argument("--is-published", type=bool, default=None, help="Filter by publish status")

    # -- get --
    p = sub.add_parser("get", help="Get conversation details")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")

    # -- delete --
    p = sub.add_parser("delete", help="Terminate a conversation")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")

    # -- health --
    p = sub.add_parser("health", help="Check sandbox services health")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")

    # -- urls --
    p = sub.add_parser("urls", help="Get sandbox, preview, and backend URLs")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")

    # -- download --
    p = sub.add_parser("download", help="Download workspace")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")

    # -- versions --
    p = sub.add_parser("versions", help="List versions")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")
    p.add_argument("--sort-order", default="desc", choices=["asc", "desc"], help="Sort direction (default: desc)")

    # -- current-version --
    p = sub.add_parser("current-version", help="Get current version info")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")

    # -- restore-version --
    p = sub.add_parser("restore-version", help="Restore to a specific version")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")
    p.add_argument("--version", type=int, required=True, help="Version number to restore")

    # -- copy --
    p = sub.add_parser("copy", help="Copy a conversation")
    p.add_argument("-c", "--conversation-id", required=True, help="Source conversation ID")
    p.add_argument("--new-name", default=None, help="Name for the copied conversation")
    p.add_argument("-w", "--wait", action="store_true", help="Auto-poll copy status until workspace is ready")

    # -- rename --
    p = sub.add_parser("rename", help="Rename a conversation")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")
    p.add_argument("--name", required=True, help="New conversation name")

    # -- publish --
    p = sub.add_parser("publish", help="Trigger deployment to production")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID to publish")
    p.add_argument("-w", "--wait", action="store_true", help="Auto-poll publish status until published or failed")
    p.add_argument("--env", default=None, help="Target environment (default: PRODUCE)")

    # -- publish-status --
    p = sub.add_parser("publish-status", help="Check the status of a deployment")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")

    # -- market-list --
    p = sub.add_parser("market-list", help="List app market items")
    p.add_argument("--limit", type=int, default=20, help="Max results (default: 20)")
    p.add_argument("--offset", type=int, default=0, help="Pagination offset (default: 0)")
    p.add_argument("--keyword", default=None, help="Search keyword")
    p.add_argument("--category-id", type=int, default=None, help="Filter by category ID")
    p.add_argument("--sort-by", default=None, help="Sort field (e.g. view_count, clone_count, created_at)")

    # -- clone-market --
    p = sub.add_parser("clone-market", help="Clone an app from the market")
    p.add_argument("--item-id", required=True, help="Market item ID to clone")

    # -- delete-version --
    p = sub.add_parser("delete-version", help="Delete a WIP version")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")
    p.add_argument("--version", type=int, required=True, help="Version number to delete")

    # -- finalize-version --
    p = sub.add_parser("finalize-version", help="Finalize current WIP version")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")

    # -- update-version-desc --
    p = sub.add_parser("update-version-desc", help="Update version description")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")
    p.add_argument("--version", type=int, required=True, help="Version number")
    p.add_argument("--description", required=True, help="New description for the version")

    # -- spending --
    p = sub.add_parser("spending", help="Get message-level spending for a conversation")
    p.add_argument("-c", "--conversation-id", required=True, help="Conversation ID")
    p.add_argument("--message-id", default=None, help="Filter by specific message ID")
    p.add_argument("--limit", type=int, default=50, help="Max results (default: 50)")
    p.add_argument("--offset", type=int, default=0, help="Pagination offset (default: 0)")

    return parser


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

# Command dispatch table
COMMANDS = {
    "create": cmd_create,
    "models": cmd_models,
    "upload": cmd_upload,
    "chat": cmd_chat,
    "poll": cmd_poll,
    "stream": cmd_stream,
    "messages": cmd_messages,
    "result": cmd_result,
    "stop": cmd_stop,
    "start-runtime": cmd_start_runtime,
    "list": cmd_list,
    "get": cmd_get,
    "delete": cmd_delete,
    "health": cmd_health,
    "urls": cmd_urls,
    "download": cmd_download,
    "versions": cmd_versions,
    "current-version": cmd_current_version,
    "restore-version": cmd_restore_version,
    "copy": cmd_copy,
    "rename": cmd_rename,
    "publish": cmd_publish,
    "publish-status": cmd_publish_status,
    "market-list": cmd_market_list,
    "clone-market": cmd_clone_market,
    "delete-version": cmd_delete_version,
    "finalize-version": cmd_finalize_version,
    "update-version-desc": cmd_update_version_desc,
    "spending": cmd_spending,
}


def main():
    """Parse CLI arguments and dispatch to the appropriate handler."""
    parser = build_parser()
    args = parser.parse_args()

    # Resolve [REDACTED_SECRET] arg > CREO4U_SKILL_API_KEY env var > prompt user
    [REDACTED_SECRET]
    if not [REDACTED_SECRET]"CREO4U_SKILL_API_KEY")

    if not token:
        # Prompt user for API key
        print("Warning: CREO4U_SKILL_API_KEY environment variable not set.", file=sys.stderr)
        [REDACTED_SECRET]"Please enter your API key: ").strip()
        if not token:
            _die("API key is required")

    # Resolve configuration
    cfg = {
        "token": token,
        "base_url": args.base_url.rstrip("/"),
        "timeout": args.timeout,
        "verify": not args.insecure,
    }

    # Dispatch
    handler = COMMANDS.get(args.command)
    if handler is None:
        _die(f"Unknown command: {args.command}")

    try:
        handler(args, cfg)
    except KeyboardInterrupt:
        sys.exit(0)
    except SystemExit:
        raise
    except Exception as exc:
        _die(str(exc))


if __name__ == "__main__":
    main()


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