Skill: gxcun17/skywork-ppt (version 1.0.9)

--- name: Skywork-ppt description: "Skywork PPT (skywork) - Use when the user wants to work with PowerPoint presentations. Triggers: (1) Generate a new PPT from a topic — 'generate a PPT', 'create a presentation about X', 'make slides', '帮我做个PPT', '生成演示文稿', 'PPTを作って', 'スライドを作って', 'PPT 만들어줘', '슬라이드 만들어줘'; (2) Imitate an existing .pptx style/template — 'use this template', 'imitate this PPT/style', '用这个模板', '仿照这个PPT', 'このテンプレートを使って', '이 템플릿 써줘'; (3) Edit existing PPT via natural language — 'modify slide N', 'change background', 'add a slide', 'make it more beautiful', '修改第N页', '更换背景', '新增一页', '美化一下', 'N枚目を修正', '背景を変えて', 'きれいにして', 'N번 슬라이드 수정', '배경 바꿔줘'; (4) Convert NotebookLM-exported pptx/pdf into editable pptx — 'make this notebooklm ppt editable', 'convert notebooklm export to editable pptx', '把这个notebooklm导出的pptx改成可编辑pptx', '把这个pdf转成可编辑pptx'; (5) Local .pptx file ops — 'delete slide N', 'reorder slides', 'merge pptx', 'extract slides', 'how many slides', '删除第N页', '调整顺序', '合并PPT', '提取幻灯片', 'スライドを削除/並べ替え/結合/抽出', '슬라이드 삭제/순서 변경/합치기/추출'." metadata: openclaw: requires: bins: - python3 env: - SKYWORK_API_KEY primaryEnv: SKYWORK_API_KEY --- # PPT Write Skill Five capabilities: **generate**, **template imitation**, **edit existing PPT**, **convert NotebookLM export to editable PPTX**, and **local file operations**. --- ## Prerequisites ### API Key Configuration (Required First) This skill requires a **SKYWORK_API_KEY** to be configured before use. If you don't have an API key yet, please visit: **https://skywork.ai** For detailed setup instructions, see: [references/apikey-fetch.md](references/apikey-fetch.md) --- ## Privacy & Remote Calls (Read Before Use) - **Remote upload & processing**: Layers 1/2/4/5 upload local files and send the full, verbatim user query to the Skywork service. Avoid sensitive or confidential content unless you trust the remote service and its data handling policies. - **Polling behavior**: The generation/edit workflows include periodic status polling (about every 5 seconds) while waiting for backend jobs. This is expected. --- ## Routing — Identify the user's intent first | User intent | Which path | |-------------|------------| | Generate a new PPT from a topic, set of requirements or reference files | **Layer 1** — Generate | | Use an existing .pptx as a layout/style template to create a new presentation | **Layer 2** — Imitate | | Edit an existing PPT: modify slides, add slides, change style, split/merge | **Layer 4** — Edit | | Convert a NotebookLM-exported `.pptx` or `.pdf` into an editable `.pptx` | **Layer 5** — Convert to editable PPTX | | Delete / reorder / extract / merge slides in a local file (no backend) | **Layer 3** — Local ops | --- ## Environment check (always run this first) **This skill requires Python 3 (>=3.8). Run the following before any script to locate a valid Python binary and install dependencies.** ```bash PYTHON_CMD="" for cmd in python3 python python3.13 python3.12 python3.11 python3.10 python3.9 python3.8; do if command -v "$cmd" &>/dev/null && "$cmd" -c "import sys; exit(0 if sys.version_info >= (3,8) else 1)" 2>/dev/null; then PYTHON_CMD="$cmd" break fi done if [ -z "$PYTHON_CMD" ]; then echo "ERROR: Python 3.8+ not found." echo "Install on macOS: brew install python3 or visit https://www.python.org/downloads/" exit 1 fi echo "Found Python: $PYTHON_CMD ($($PYTHON_CMD --version))" $PYTHON_CMD -m pip install -q --break-system-packages python-pptx echo "Dependencies ready." ``` > After this check, replace `python` with the discovered `$PYTHON_CMD` (e.g. `python3`) in all subsequent commands. --- ## Layer 1 — Generate PPT ### Steps 0. **REQUIRED FIRST STEP** — Read [workflow_generate.md](workflow_generate.md) NOW, before taking any other action. After reading, output exactly: `✅ workflow_generate.md loaded.` — then proceed. 1. **Environment check** — run the check above to get `$PYTHON_CMD`. 2. **Upload reference files** (if the user provides local files as content source) — parse the file using tool in script/parse_file.py and pass the result to `--files`. See the `--files` note below. 3. **Web search** (required if no relevant content is already in the conversation) — call web_search tool in script to search the topic and distill results into a `reference-file` file of ≤ 2000 words. 4. **Run the script**: > **Important**: set exec tool `yieldMs` to `600000` (10 minutes). 5. **Deliver** — provide the absolute `.pptx` path and the download URL. --- ## Layer 2 — Imitate PPT (template-based generation) ### Steps 0. **REQUIRED FIRST STEP** - Read [workflow_imitate.md](workflow_imitate.md) immidiately before any action you do!!! 1. **Environment check** — run the check above to get `$PYTHON_CMD`. 2. **Locate the template** — extract the absolute path of the local `.pptx` from the user's message; ask the user if it's unclear. 3. **Upload the template** — upload it and extract `TEMPLATE_URL` from the output. 4. **Upload reference files** (if the user provides additional local files as content source) — parse the file using tool in script/parse_file.py and pass the result to `--files`. See the `--files` 5. **Web search** (required if no relevant content is already in the conversation) — call web_search tool in script to search the new topic and distill results into a `reference-file` file of ≤ 2000 words. 6. **Run the script**: > **Important**: set exec tool `yieldMs` to `600000` (10 minutes). 7. **Deliver** — provide the absolute `.pptx` path, the download URL, and the template filename used. --- ## Layer 4 — Edit PPT (AI-powered modification) Use this layer when the user wants to modify an existing PPT using natural language. Requires an OSS/CDN URL of the PPTX (from a previous generation or upload). ### Steps 0. **Detailed workflow** - Read [workflow_edit.md](workflow_edit.md) immediately before any action you do!!! 1. **Environment check** — run the check above to get `$PYTHON_CMD`. 2. **Get PPTX URL** — from the user's message or upload a local file first. 3. **Run the script** with `--pptx-url`: ```bash $PYTHON_CMD scripts/run_ppt_write.py "edit instruction" \ --language Chinese \ --pptx-url "https://cdn.example.com/file.pptx" \ -o /absolute/path/output.pptx ``` > **Important**: set exec tool `yieldMs` to `600000` (10 minutes). 4. **Deliver** — provide download link, local path, and summary of changes. --- ## Layer 5 — Convert NotebookLM Export to Editable PPTX Use this layer when the user provides a local `.pptx` or `.pdf` exported from NotebookLM and wants an editable `.pptx`. ### Steps 0. **Detailed workflow** - Read [workflow_convert_editable.md](workflow_convert_editable.md) immediately before any action you do!!! 1. **Environment check** — run the check above to get `$PYTHON_CMD`. 2. **Locate the source file** — extract the absolute path of the local `.pptx` or `.pdf`; ask the user if the path is unclear. 3. **Upload the source file** — upload it and extract `SOURCE_URL` from the output. 4. **Run the script**: ```bash $PYTHON_CMD scripts/run_editable_convert.py \ --file-url "https://cdn.example.com/source.pptx" \ --file-type "pptx" \ -o /absolute/path/output_editable.pptx ``` > **Important**: set exec tool `yieldMs` to `600000` (10 minutes). 5. **Deliver** — provide download link, local path, and the original filename used as the source. --- ## Layer 3 — PPT File operations ```bash # Inspect slide count and titles $PYTHON_CMD scripts/local_pptx_ops.py info --file my.pptx # Delete slides (1-based index; supports ranges like 3,5,7-9; omit -o to overwrite in place) $PYTHON_CMD scripts/local_pptx_ops.py delete --file my.pptx --slides 3,5,7-9 -o trimmed.pptx # Reorder slides (must list every slide, no omissions) $PYTHON_CMD scripts/local_pptx_ops.py reorder --file my.pptx --order 2,1,4,3,5 # Extract a subset of slides into a new file $PYTHON_CMD scripts/local_pptx_ops.py extract --file my.pptx --slides 1-3 -o subset.pptx # Merge multiple files $PYTHON_CMD scripts/local_pptx_ops.py merge --files a.pptx b.pptx -o merged.pptx ``` Read [workflow_local.md](workflow_local.md) immidiately before any action you do!!! --- ## Error Handling - **Insufficient benefit**: When calling scripts (generate, imitate, edit, or convert to editable), the script or log may show a message like `Insufficient benefit. Please upgrade your account at {url}`, meaning the user's benefit level does not meet the requirement for this skill. ### How to reply when benefit is insufficient When you detect the above, **reply in the user's current language** — do not echo the English message. Use this pattern: - Convey: "Sorry, PPT generation failed. This skill requires upgrading your Skywork membership to use." then a single call-to-action link. - **Format**: One short sentence in the user's language + a link like `[Upgrade now →](url)` or the equivalent in their language . - **URL**: Extract the upgrade URL from the log/script output (e.g. the `at https://...` part). > Note: Only suggest upgrading when the error is **Insufficient benefit**. For auth errors like `NO_TOKEN` / `INVALID_TOKEN` / `401` / “invalid API key”, keep the error code / raw message and guide users to update `SKYWORK_API_KEY`. **Do not** suggest upgrading membership. --- ## Dependencies - **Python 3.8+** (required) — `python3` / `python` must be on PATH - Layer 3 local ops: `pip install python-pptx --break-system-packages` (The environment check step installs all required dependencies automatically.) --- ## Which layer to trigger? | Scenario | Use | |----------|-----| | Generate a PPT from a topic or existing reference files | Layer 1 | | Imitate the layout/style of an existing .pptx | Layer 2 | | Edit/modify an existing PPT via natural language | Layer 4 | | Convert NotebookLM-exported `.pptx` / `.pdf` into editable `.pptx` | Layer 5 | | Delete / reorder / extract / merge local .pptx files (no backend) | Layer 3 |

path: workflow_convert_editable.md
content: # Convert NotebookLM Export to Editable PPTX Workflow

Use this workflow when the user provides a local `.pptx` or `.pdf` file and wants to convert a NotebookLM-exported deck into an editable `.pptx`.

Follow these steps in order: identify the source file → upload it → run the conversion script in background → **read the log every 5 seconds and report progress** → deliver.

---

## 1. Confirm the source file

The user must provide a local `.pptx` or `.pdf` file.

- If the file path is ambiguous, ask the user for the full absolute path.
- If the file extension is not `.pptx` or `.pdf`, stop and explain that this workflow only supports those two input types.

---

## 2. Upload the source file to OSS

```bash
$PYTHON_CMD scripts/upload_files.py "/absolute/path/to/source.pptx"
```

or

```bash
$PYTHON_CMD scripts/upload_files.py "/absolute/path/to/source.pdf"
```

- Extract the OSS URL from the `[OK] ... -> https://...` output line.
- Call this value `SOURCE_URL`.

---

## 3. Run the conversion script

Run in the **background**, then read the progress log file every 5 seconds until done.

### 3a. Choose a log path and start in background

```bash
PPT_LOG=/tmp/ppt_editable_$(date +%s).log

$PYTHON_CMD scripts/run_editable_convert.py \
  --file-url "SOURCE_URL" \
  --file-type "pptx" \
  --log_path "$PPT_LOG" \
  -o /absolute/path/to/output_editable.pptx \
  > /dev/null 2>&1 &

echo "Log: $PPT_LOG"
```

or for pdf:

```bash
$PYTHON_CMD scripts/run_editable_convert.py \
  --file-url "SOURCE_URL" \
  --file-type "pdf" \
  --log_path "$PPT_LOG" \
  -o /absolute/path/to/output_editable.pptx \
  > /dev/null 2>&1 &
```

- **`--file-url`** (required): OSS URL of the uploaded source file.
- **`--file-type`** (recommended): explicitly pass `pptx` or `pdf`.
- **`--log_path`** (required): pass the pre-chosen path.
- **`-o`** (required): absolute output path for the converted `.pptx`.

### 3b. Monitor progress （REQUIRED）

> **STRICT RULES — no exceptions:**
> 1. **Read the log exactly every 5 seconds.** Do NOT extend the interval, do NOT skip reads.
> 2. **Before every read, check if the process is still alive.** If alive → only read log, NEVER restart.

**Every 5 seconds, run this exact sequence:**

```bash
# Step 1: extract PID from log
PPT_PID=$(grep '^\[PID\]' "$PPT_LOG" | tail -1 | awk '{print $2}')
# Step 2: check if process is alive
kill -0 "$PPT_PID" 2>/dev/null && PPT_ALIVE=true || PPT_ALIVE=false
# Step 3: read the log regardless
tail -20 "$PPT_LOG"
```

- If process is **RUNNING** → report status to user, wait 5s, repeat. Do NOT touch the script.
- If process is **not running** AND log ends with `[DONE]` or `[ERROR]` → stop polling, proceed to deliver/handle error.
- If process is **not running** AND no `[DONE]`/`[ERROR]` in log → the script crashed; report error to user, ask whether to retry.

Each line is plain text:
- `[PID] <pid>` — process ID written at startup
- `[START]` — job started
- `[PHASE] <message>` — in progress
- `[PROGRESS] <message>` — heartbeat progress update
- `[DONE] saved=<path> download_url=<url>` — finished
- `[ERROR] <message>` — failed

**After each read, report status to the user:**

```text
[Main stage] | [current action]
Example: Converting HTML | Converting batch 2/4
```

Progress phases:

| Message contains | Main stage |
|-----------------|------------|
| "Capturing screenshots" / "Screenshots captured" | Capturing screenshots |
| "Uploading screenshot" / "Screenshot upload complete" | Uploading screenshots |
| "Building nano HTML" / "Nano HTML ready" | Building nano HTML |
| "Converting HTML" / "Converted batch" | Converting HTML |
| "Exporting editable PPTX" / "Export complete" | Exporting PPTX |

**Stop polling** as soon as you see `[DONE]` or `[ERROR]`.

---

## 4. Deliver

Provide all of the following:
1. Download link (`download_url` from the completion event)
2. Local `.pptx` absolute path
3. The original filename used as the source

### Failure

Briefly explain the error.


path: workflow_edit.md
content: # PPT Edit Workflow

Edit an existing PPT via natural language: modify individual slides, add new slides, change the overall style, split or merge slides.

Follow these steps in order: get pptx_url → confirm intent → run script (background) → **read log every 5s and report progress** (required) → deliver. **Never go silent — always report status as you poll the log.**

---

## 1. Get the PPTX URL

The user must supply a publicly accessible OSS/CDN URL of the existing PPTX to edit.

- If the user provides a **local file path** instead of a URL, upload it first:
  ```bash
  $PYTHON_CMD scripts/upload_files.py "/absolute/path/to/file.pptx"
  ```
  Extract the URL from the `[OK] ... -> https://...` output line.

- If the URL is already provided (e.g. from a previous generation's `Download URL:` output), use it directly.

---

## 2. Confirm edit intent

Understand what the user wants to change. Supported operations include:

| Operation | Example user intent |
|-----------|---------------------|
| Modify a slide | "change page 4 background to black, text to white" |
| Add a new slide | "insert a new slide after page 3 about X" |
| Change overall style | "make all slides use a dark theme" |
| Split a slide | "split page 5 into two slides" |
| Merge slides | "merge pages 2 and 3 into one slide" |

Pass the user's original instruction verbatim as `query`. Do not paraphrase — the backend interprets it directly.

---

## 3. Run the script

Run in the **background**, then read the progress log file every 5 seconds until done.

### 3a. Choose a log path and start in background
```bash
PPT_LOG=/tmp/ppt_$(date +%s).log

$PYTHON_CMD scripts/run_ppt_write.py "user's edit instruction" \
  --language Chinese \
  --pptx-url "https://cdn.example.com/path/to/file.pptx" \
  --log_path "$PPT_LOG" \
  -o /absolute/path/to/output.pptx \
  > /dev/null 2>&1 &

echo "Log: $PPT_LOG"
```

- **`--log_path`** (required): Pass the pre-chosen path. The script writes all progress here.
- **`--pptx-url`** (required): OSS URL of the PPTX to edit. This triggers edit mode.
- **`--language`** (required): Detect from user input — `Chinese` or `English`.
- **`-o`** (required): Absolute output path for the edited PPTX.

### 3b. Monitor progress （REQUIRED）

> **STRICT RULES — no exceptions:**
> 1. **Read the log exactly every 5 seconds.** Do NOT extend the interval, do NOT skip reads.
> 2. **Before every read, check if the process is still alive.** If alive → only read log, NEVER restart.

**Every 5 seconds, run this exact sequence:**
```bash
# Step 1: extract PID from log
PPT_PID=$(grep '^\[PID\]' "$PPT_LOG" | tail -1 | awk '{print $2}')
# Step 2: check if process is alive
kill -0 "$PPT_PID" 2>/dev/null && PPT_ALIVE=true || PPT_ALIVE=false
# Step 3: read the log regardless
tail -20 "$PPT_LOG"
```
- If process is **RUNNING** → report status to user, wait 5s, repeat. Do NOT touch the script.
- If process is **not running** AND log ends with `[DONE]` or `[ERROR]` → stop polling, proceed to deliver/handle error.
- If process is **not running** AND no `[DONE]`/`[ERROR]` in log → the script crashed; report error to user, ask whether to retry. **Both conditions must hold: PID gone AND no terminal log line. NEVER retry without explicit user confirmation.**

Each line is plain text: 
- `[PID] <pid>` — process ID written at startup
- `[START]` — job started
- `[PHASE] <message>` — in progress
- `[DONE] saved=<path> download_url=<url>` — finished
- `[ERROR] <message>` — failed

**After each read, report status to the user:**
```
[Main stage] | [current action] 
Example: Generating slides | Working on page 3 
```

Progress phases:

| Message contains | Main stage |
|-----------------|------------|
| "Parsing existing PPTX" | Parsing original PPTX |
| "update plan" | Planning updates |
| "updated slides" / "Slide N updated" | Generating updated slides |
| "Exporting" / "Export complete" | Exporting PPTX |

**Stop polling** as soon as you see `[DONE]` or `[ERROR]`.

---

## 4. Deliver

Provide all of the following:
1. Download link (`download_url` from the completion event)
2. Local `.pptx` absolute path
3. Brief summary of what was changed (which pages, what modifications)

### Failure

Briefly explain the error.


path: workflow_generate.md
content: # PPT Generate Workflow

Follow these steps in order: confirm inputs → run script (streaming) → **output progress at every phase** (required) → save file → deliver. **Never go silent for an extended period — always stream progress as it arrives.**

---

## 1. Parse reference files (optional — only if the user provides local files)

If the user provides local files (PDF, DOCX, PPTX, images, etc.) as content source for the PPT, parse each file with the document parse service. This uploads the file, extracts its content, and returns metadata (file_id, file_url).

Run `parse_file.py` once per filet, his may cost a few minutes to finish completely::

```bash
$PYTHON_CMD scripts/parse_file.py /path/to/file1.pdf
$PYTHON_CMD scripts/parse_file.py /path/to/file2.docx
```
- **exec tool `yieldMs`**: Must be set to `600000` (10 minutes) when invoking the exec tool.

Each call prints a `PARSED_FILE:` line on success:
```
PARSED_FILE: {"filename": "file1.pdf", "url": "https://...", "file_id": "123456789"}
```

Collect every `PARSED_FILE:` result and assemble them into a JSON array as `DOC_FILES_JSON`:
```json
[
  {"filename":"file1.pdf","url":"https://...","file_id":"123456789","file_type":"pdf"},
  {"filename":"file2.docx","url":"https://...","file_id":"987654321","file_type":"docx"}
]
```

Pass this array to `--files` in Step 3. If no local files are provided, skip this step entirely.

---

## 2. Web search (required if no relevant content is already in the conversation)

Skip this step if the user has already provided sufficient reference material or reference files.
Otherwise, run up to 3 targeted searches on the topic, make sure your query contains the date if you need to search the latest information:

### Run searches

```bash
$PYTHON_CMD scripts/web_search.py "query1" "query2" "query3"
```

- Break the topic into 1–3 focused queries (e.g. for "Beijing travel guide": "Beijing top attractions", "Beijing food recommendations", "Beijing transportation tips").
- **If the topic involves current events, trends, statistics, or anything time-sensitive**, append the current year (e.g. `2026`) or a date range to each query to ensure results are up to date. Example: `"AI agent market trends 2026"` instead of `"AI agent market trends"`.
- Deduplicate and distill all results into a `reference` text to enrich the PPT content.

### Synthesize search results

**If reference files were parsed in Step 1**, use their `parsed_content` as the primary source and supplement with search results. The parsed content contains the full document text — extract and preserve all detail, do not compress it into a short summary.

Produce a detailed **reference report**. **Target 800–2000 words — do not stop early.** Write as much specific detail as the sources provide:

```
## Key Findings
3–5 sentences covering the main conclusions, background, and why it matters.

## Key Data & Facts
- List every specific number, percentage, date, and metric found. Do not skip data.
- Format: "Exact figure — what it measures (source)"
- Aim for at least 5 data points if the source contains them.

## Topic Breakdown
Organize by sub-topic. For each sub-topic write 3–5 specific bullet points.
Do not use vague phrases like "it is important" — state the actual fact.
Preserve names of people, companies, products, places, and dates exactly as found.

## Cases & Examples (if available)
Concrete examples, case studies, or use cases with specific details and outcomes.

## Summary
3–5 bullet points capturing the core message the PPT must convey.
```

**Hard constraints**:
- Only include information that **explicitly appears** in the sources. Do not invent or infer.
- Numbers, percentages, and proper nouns must be quoted exactly — do not paraphrase or round.
- **Never produce fewer than 500 words** unless the source material is genuinely sparse.
- If a section has no relevant information, omit that section entirely.
- **Note the publication date of each source.** Always include the current date context in the summary where relevant.

After writing the reference report, save it to a local temp file to avoid shell encoding issues:
```bash
cat > /tmp/ppt_reference.md << 'REFERENCE_EOF'
<paste the full reference report here>
REFERENCE_EOF
```
Use this file path as `--reference-file` in Step 3.

---

## 2. Confirm inputs

- **query** (required): Faithfully reproduce the user's full request — include every instruction the user mentioned. Cover the following if the user specified them; do not add anything they didn't mention:
  - Slide count (if the user set a limit, it must be included — never drop it)
  - Topic / content direction
  - Style preference (e.g. business, academic, playful, minimalist)
  - Audience / purpose (e.g. "exec briefing for the CXO", "science explainer for kids")
  - Structure preference (e.g. "data-heavy", "one case study per slide")
  - Other constraints (tone, things to avoid, required talking points, etc.)

  **Rule**: Do not fabricate requirements the user never mentioned, and do not omit anything they explicitly said. If the user gives only a title, the query is exactly that title — don't add qualifiers.

  **Examples**:

  | User input | Correct query |
  |------------|---------------|
  | Make me a PPT about Beijing travel | `Beijing Travel Guide PPT` |
  | 8-page English slides on AI Agent, clean business style, targeting investors | `8-page English slides on AI Agent, clean business style, targeting investors` |
  | PPT for our Q1 sales summary, 10 slides, with data charts, formal style | `Q1 Sales Summary PPT, 10 slides, data charts, formal business style` |
  | A fun solar system PPT for elementary school kids, 6 pages | `Solar System explainer PPT for elementary school kids, fun and engaging style, 6 slides` |

- **language** (required): Detect from the user's input language — if the user writes in Chinese pass `Chinese`; English → `English`; etc. Never default to a fixed value.
- **--reference-file** (optional): Pass the path to the temp file written at the end of Step 2 (e.g. `/tmp/ppt_reference.md`). Omit if no search was performed.
- **--files** (optional): Pass the JSON array assembled from the `PARSED_FILE:` lines in Step 1. Omit if no files were provided. Format: `[{"file_name":"...","url":"...","file_id":"...", "file_type": "pdf"},...]`

---

## 3. Run the script

PPT generation takes ~10 minutes. Choose a log path first, pass it to the script, then read it every 5 seconds.

### 3a. Choose a log path and start in background
```bash
PPT_LOG=/tmp/ppt_$(date +%s).log

$PYTHON_CMD scripts/run_ppt_write.py "user query" \
  --language English \
  --reference-file /tmp/ppt_reference.md \
  --files '[{"file_name":"report.pdf","url":"https://...", "file_id":"1212121", "file_type":"pdf"}]' \
  --log_path "$PPT_LOG" \
  -o /absolute/path/to/output.pptx \
  > /dev/null 2>&1 &

echo "Log: $PPT_LOG"
```

- **`--log_path`** (required): Pass the pre-chosen path. The script writes all progress here.
- **`-o`** (required): Use an absolute path.
- **`--files`**: JSON array from `DOC_FILES_JSON:` lines **verbatim**. Omit if no reference files.

### 3b. Monitor progress （REQUIRED）

> **STRICT RULES — no exceptions:**
> 1. **Read the log exactly every 5 seconds.** Do NOT extend the interval, do NOT skip reads.
> 2. **Before every read, check if the process is still alive.** If alive → only read log, NEVER restart.

**Every 5 seconds, run this exact sequence:**
```bash
# Step 1: extract PID from log
PPT_PID=$(grep '^\[PID\]' "$PPT_LOG" | tail -1 | awk '{print $2}')
# Step 2: check if process is alive
kill -0 "$PPT_PID" 2>/dev/null && PPT_ALIVE=true || PPT_ALIVE=false
# Step 3: read the log regardless
tail -20 "$PPT_LOG"
```
- If process is **RUNNING** → report status to user, wait 5s, repeat. Do NOT touch the script.
- If process is **not running** AND log ends with `[DONE]` or `[ERROR]` → stop polling, proceed to deliver/handle error.
- If process is **not running** AND no `[DONE]`/`[ERROR]` in log → the script crashed; report error to user, ask whether to retry. **Both conditions must hold: PID gone AND no terminal log line. NEVER retry without explicit user confirmation.**

Each line is plain text:
- `[PID] <pid>` — process ID written at startup
- `[START]` — job started
- `[PHASE] <message>` — stage transition (outline, slides, export, etc.)
- `[PING] <progress>% | <stage>` — heartbeat every few seconds with current progress and stage label
- `[OUTLINE]` — outline content (appears once after outline_done)
- `[DONE] saved=<path> download_url=<url>` — finished
- `[ERROR] <message>` — failed

**After each read, report status to the user:**
```
[Main stage] | [current action]
Example: Generating slides | Working on page 3 
```

Map phase messages to main stages:

| Message contains | Main stage |
|-----------------|------------|
| "outline" | Generating outline |
| "slide" / "page" / "content" | Generating slides |
| "image" / "HTML" | Rendering images |
| "Export" / "export" | Exporting PPTX |
| "Parsing existing PPTX" | Parsing original PPTX |
| "update plan" | Planning updates |
| "template" | Processing template |

**Stop polling** as soon as you see `[DONE]` or `[ERROR]`.

---

## 4. Deliver

### Success

Provide all of the following:
1. Download link
2. Local `.pptx` absolute path
3. Presentation title
4. Brief description of each slide's content

### Failure

Briefly explain the error. 

## Technical Notes
- **Timeout**: PPT generation takes ~10 minutes, set sufficient timeout


path: workflow_local.md
content: # Local PPTX Operations Workflow (Layer 3)

No backend or token needed — uses `python-pptx` to operate on local `.pptx` files directly.

---

## Install Dependencies

```bash
pip install python-pptx
```

---

## Trigger Keywords

When the user expresses the following intents, use this workflow (instead of calling the backend API):

| User Intent | Example Keywords |
|----------|-----------|
| View file info | View PPT page count, how many pages, pptx info |
| Delete specific slides | Delete slide N, remove slide 3, delete slide 3, remove page 5 |
| Reorder slides | Reorder PPT, rearrange slide order, reorder slides |
| Extract slides | Extract slides 1-3, export certain slides, extract slides |
| Merge files | Merge two PPTs, merge pptx, combine a and b |

---

## Unified Entry Point

All operations are done via `scripts/local_pptx_ops.py`:

```bash
$PYTHON_CMD scripts/local_pptx_ops.py <subcommand> [arguments]
```

---

## Subcommand Details

### info — View file info

```bash
$PYTHON_CMD scripts/local_pptx_ops.py info --file my.pptx
```

Output: file path, total slides, dimensions, title of each slide.

---

### delete — Delete slides

```bash
# Delete slide 3
$PYTHON_CMD scripts/local_pptx_ops.py delete --file my.pptx --slides 3

# Delete slides 3, 5, 7-9, overwrite the original file
$PYTHON_CMD scripts/local_pptx_ops.py delete --file my.pptx --slides 3,5,7-9

# Save to a new file after deletion (don't overwrite the original)
$PYTHON_CMD scripts/local_pptx_ops.py delete --file my.pptx --slides 3,5,7-9 -o trimmed.pptx
```

**Note**:
- Page numbers are 1-based (slide 1 = 1)
- Supports comma-separated and `-` ranges, e.g. `1,3,5-8,10`
- If `-o` is not specified, the original file is overwritten

---

### reorder — Reorder slide order

```bash
# Move original slide 2 to position 1, slide 1 to position 2 (rest unchanged)
$PYTHON_CMD scripts/local_pptx_ops.py reorder --file my.pptx --order 2,1,3,4,5

# Save to a new file
$PYTHON_CMD scripts/local_pptx_ops.py reorder --file my.pptx --order 2,1,3,4,5 -o reordered.pptx
```

**Note**: `--order` must include all page numbers — no omissions or duplicates.

---

### extract — Extract specific slides

```bash
# Extract slides 1 through 3
$PYTHON_CMD scripts/local_pptx_ops.py extract --file my.pptx --slides 1-3 -o subset.pptx

# Extract slides 1, 3, 5
$PYTHON_CMD scripts/local_pptx_ops.py extract --file my.pptx --slides 1,3,5 -o subset.pptx
```

Extracted result is saved as a new file (original file is not modified).

---

### merge — Merge multiple files

```bash
# Merge two files
$PYTHON_CMD scripts/local_pptx_ops.py merge --files a.pptx b.pptx -o merged.pptx

# Merge three or more
$PYTHON_CMD scripts/local_pptx_ops.py merge --files a.pptx b.pptx c.pptx -o merged.pptx
```

**Note**: The slide dimensions of the first file are used as the standard; shapes are preserved as much as possible during merging, but complex animations/video effects may be lost.

---

## Agent Decision Logic

User intent → Route selection:

```
User says "Generate a PPT about X"
  → Layer 1: scripts/run_ppt_write.py (call backend to generate)

User says "Delete slide 3 from this PPT" / "Merge these two pptx" / "Extract the first 5 slides"
  → Layer 3: scripts/local_pptx_ops.py (local operation, no token needed)
```

---

## FAQ

**Q: Do page numbers change after deletion?**
A: Yes. After deletion, page numbers are renumbered starting from 1. If you need to delete multiple slides, it's recommended to pass them all at once (`--slides 3,5,7`) rather than running multiple times.

**Q: What if styles are wrong after merging?**
A: Merging uses deep-copy of the shape tree; themes/masters come from the first file. If there are significant style differences, manual adjustments in PowerPoint are recommended.

**Q: Does python-pptx support deleting slides?**
A: Not natively. This tool operates on the underlying XML (`_sldIdLst`), consistent with common python-pptx community approaches.


path: workflow_imitate.md
content: # PPT Imitation Workflow

The user provides an existing PPTX file as a layout/style reference. Generate a new presentation on a different topic that follows the same visual structure.

Follow these steps in order: identify intent → locate template → web search → upload template → run script (streaming) → **output progress at every phase** (required) → save file → deliver. **Never go silent for an extended period — always stream progress as it arrives.**

---

## 0. Confirm imitation intent

Use this workflow (instead of the standard generate flow) when **both** of the following are true:

- The user mentions a template, style reference, or an existing file — e.g. "use this template", "match the style of this PPT", "imitate this layout" — **or** provides a `.pptx` file path or filename.
- The user has a new generation request (topic, content, slide count, etc.).

If the user provides a template but no new topic, **ask what they want to create** before proceeding.

---

## 1. Locate the local template file

Extract the local PPTX path from the user's message. It may appear as:

- An absolute path: `/Users/xxx/templates/style.pptx`
- A relative path or filename: `my_template.pptx` (ask the user to confirm the exact full path before using it)
- An attachment uploaded in the conversation (the platform will provide a local temp path)

**If the path is ambiguous, ask the user for the full absolute path.**

---

## 2. Parse reference files (optional — only if the user provides local files as content source)

If the user provides additional local files (PDF, DOCX, PPTX, images, etc.) to use as content source — separate from the template — parse each file with the document parse service.

Run `parse_file.py` once per file, this may cost a few minutes to finish completely:

```bash
$PYTHON_CMD scripts/parse_file.py /path/to/file1.pdf
$PYTHON_CMD scripts/parse_file.py /path/to/file2.docx
```
- **exec tool `yieldMs`**: Must be set to `600000` (10 minutes) when invoking the exec tool.

Each call prints a `PARSED_FILE:` line on success:
```
PARSED_FILE: {"filename": "file1.pdf", "url": "https://...", "file_id": "123456789"}
```

Collect every `PARSED_FILE:` result and assemble them into a JSON array as `DOC_FILES_JSON`:
```json
[
  {"filename":"file1.pdf","url":"https://...","file_id":"123456789","file_type":"pdf"},
  {"filename":"file2.docx","url":"https://...","file_id":"987654321","file_type":"docx"}
]
```

Pass this array to `--files` in Step 6. If no additional content files are provided, skip this step entirely.

---

## 3. Web search (required if no relevant content is already in the conversation)

Skip if the user has already provided sufficient reference material or reference files.
Otherwise, run up to 3 targeted searches on the **new PPT topic**:

```bash
$PYTHON_CMD scripts/web_search.py "query1" "query2" "query3"
```

Produce a detailed reference report following the same format and constraints as the generate workflow (target 800–2000 words, preserve all specific details, never fewer than 500 words).

After writing the reference report, save it to a local temp file:
```bash
cat > /tmp/ppt_reference.md << 'REFERENCE_EOF'
<paste the full reference report here>
REFERENCE_EOF
```
Use this file path as `--reference-file` in Step 6.

---

## 4. Upload the template to OSS

```bash
$PYTHON_CMD scripts/upload_files.py "/absolute/path/to/template.pptx"
```

- Script output format: `[OK] /path/to/file.pptx -> https://cdn.xxx/skills/upload/yyyy-mm-dd/uuid_filename.pptx`
- Extract the OSS URL from the output and call it `TEMPLATE_URL`.
- If the upload fails, inform the user and stop.

---

## 5. Confirm generation parameters

- **query** (required): Faithfully reproduce the user's full request — include every instruction (slide count, style, audience, etc.). Do not fabricate or omit anything.
- **language** (required): Language for the slides, e.g. `Chinese`, `English`.
- **language** (required): Detect from the user's input language — if the user writes in Chinese pass `Chinese`; English → `English`; etc. Never default to a fixed value.
- **template_urls** (required): The OSS URL(s) from Step 4. Multiple URLs are supported (comma-separated) — the backend selects the best-matching layout from each. One URL is sufficient in most cases; pass multiple only if the user provided several template files.
- **--reference-file** (optional): Path to the temp file written at the end of Step 3 (e.g. `/tmp/ppt_reference.md`). Omit if no search was performed.
- **--files** (optional): Pass the JSON array assembled from the `PARSED_FILE:` lines in Step 2. Omit if no content files were provided. Format: `[{"filename":"...","url":"...","file_id":"...", "file_type": "pdf"},...]`

---

## 6. Run the script

PPT generation takes ~10 minutes. Run it in the **background**, then read the progress log file every 5 seconds until done.

### 6a. Choose a log path and start in background
```bash
PPT_LOG=/tmp/ppt_$(date +%s).log

$PYTHON_CMD scripts/run_ppt_write.py "user query" \
  --language Chinese \
  --template_urls "TEMPLATE_URL1,TEMPLATE_URL2" \
  --reference-file /tmp/ppt_reference.md \
  --files '[{"file_name":"report.pdf","url":"https://...", "file_id":"1212121", "file_type":"pdf"}]' \
  --log_path "$PPT_LOG" \
  -o /absolute/path/to/output.pptx \
  > /dev/null 2>&1 &

echo "Log: $PPT_LOG"
```

- **`--log_path`** (required): Pass the pre-chosen path. The script writes all progress here.
- **`-o`** (required): Use an absolute path for reliable delivery.
- **`--template_urls`**: Comma-separated OSS URLs of template files from Step 4.
- **`--files`**: Pass the JSON array assembled from `PARSED_FILE:` lines **verbatim**. Omit if no reference files.

### 6b. Monitor progress （REQUIRED）

> **STRICT RULES — no exceptions:**
> 1. **Read the log exactly every 5 seconds.** Do NOT extend the interval, do NOT skip reads.
> 2. **Before every read, check if the process is still alive.** If alive → only read log, NEVER restart.

**Every 5 seconds, run this exact sequence:**
```bash
# Step 1: extract PID from log
PPT_PID=$(grep '^\[PID\]' "$PPT_LOG" | tail -1 | awk '{print $2}')
# Step 2: check if process is alive
kill -0 "$PPT_PID" 2>/dev/null && PPT_ALIVE=true || PPT_ALIVE=false
# Step 3: read the log regardless
tail -20 "$PPT_LOG"
```
- If process is **RUNNING** → report status to user, wait 5s, repeat. Do NOT touch the script.
- If process is **not running** AND log ends with `[DONE]` or `[ERROR]` → stop polling, proceed to deliver/handle error.
- If process is **not running** AND no `[DONE]`/`[ERROR]` in log → the script crashed; report error to user, ask whether to retry. **Both conditions must hold: PID gone AND no terminal log line. NEVER retry without explicit user confirmation.**


Each line is plain text: 
- `[PID] <pid>` — process ID written at startup
- `[START]` — job started
- `[PHASE] <message>` — in progress
- `[DONE] saved=<path> download_url=<url>` — finished
- `[ERROR] <message>` — failed

**After each read, report status to the user:**
```
[Main stage] | [current action] 
Example: Generating slides | Working on page 3 
```

**Stop polling** as soon as you see `[DONE]` or `[ERROR]`.


## 7. Deliver

### Success

Provide all of the following:
1. Download link
2. Local `.pptx` absolute path
3. Presentation title
4. Brief description of each slide's content
5. Template filename used (so the user can verify)

### Failure

Briefly explain the error.
---

## Appendix — Intent classification examples

| User input | Use this workflow? |
|------------|--------------------|
| Make me a PPT about Beijing travel | ❌ Standard generate |
| Use this template to make a PPT on AI trends — template at `/tmp/style.pptx` | ✅ Imitate |
| Imitate the uploaded pptx style, 10-slide quarterly review | ✅ Imitate |
| What style is this template? | ❌ Not a generation request — just answer the question |


path: scripts/upload_files.py
content: #!/usr/bin/env python3
"""
Upload local files to OSS by calling the /upload_oss endpoint.

Usage:
  python upload_files.py file1.png file2.jpg
  python upload_files.py file1.png --path skills/images/
"""
import argparse
import io
import json
import mimetypes
import os
import sys
import urllib.request
import urllib.error
import uuid

from pptx import api


from constant import SKYWORK_GATEWAY_URL

from skywork_auth import get_skywork_api_key


def build_multipart(fields: dict, files: dict) -> tuple[bytes, str]:
    """Build a multipart/form-data request body, returns (body_bytes, content_type).

    fields: {"field_name": "value", ...}
    files:  {"field_name": (filename, file_bytes, mime_type), ...}
    """
    boundary = uuid.uuid4().hex
    parts = []

    for name, value in fields.items():
        parts.append(
            f'--{boundary}\r\n'
            f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
            f'{value}\r\n'
        )

    for name, (filename, data, mime) in files.items():
        parts.append(
            f'--{boundary}\r\n'
            f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
            f'Content-Type: {mime}\r\n\r\n'
        )
        if isinstance(data, str):
            data = data.encode()
        parts_bytes = b''.join(
            p.encode() if isinstance(p, str) else p for p in parts
        ) + data + b'\r\n'
        parts = [parts_bytes]

    body = b''.join(p.encode() if isinstance(p, str) else p for p in parts)
    body += f'--{boundary}--\r\n'.encode()
    content_type = f'multipart/form-data; boundary={boundary}'
    return body, content_type


def upload_file(local_path: str, oss_prefix: str | None, base_url: str, api_key: str = None) -> str:
    """Upload a single local file and return the OSS URL. Raises an exception on failure."""
    if not os.path.isfile(local_path):
        raise FileNotFoundError(f"File not found: {local_path}")

    filename = os.path.basename(local_path)
    mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"

    with open(local_path, "rb") as f:
        file_bytes = f.read()

    fields = {}
    if oss_prefix:
        fields["path"] = oss_prefix

    body, content_type = build_multipart(
        fields=fields,
        files={"file": (filename, file_bytes, mime)},
    )

    url = base_url.rstrip("/") + "/upload_oss"
    headers = {
        "Content-Type": content_type,
    }
    if [REDACTED_SECRET]"Authorization"] = f"Bearer {api_key}"
    req = urllib.request.Request(
        url,
        data=body,
        method="POST",
        headers=headers,
    )

    with urllib.request.urlopen(req, timeout=60) as resp:
        raw = resp.read().decode("utf-8", errors="replace")

    result = json.loads(raw)
    code = result.get("code")
    if code != 200:
        raise RuntimeError(f"Upload failed code={code} msg={result.get('msg')}")

    return result["url"]


def upload_files(local_paths: list[str], oss_prefix: str | None = None, base_url: str = SKYWORK_GATEWAY_URL, api_key: str = None) -> list[dict]:
    """Upload a batch of local files and return a list of results for each file.

    Return format:
        [{"path": "/local/file.png", "url": "https://cdn.../xxx.png", "ok": True}, ...]
        On failure, url is empty, ok is False, and an error field is included.
    """
    results = []
    for path in local_paths:
        try:
            url = upload_file(path, oss_prefix, base_url, api_key)
            filename = os.path.basename(path)
            print(f"[OK] {path} -> {url}", flush=True)
            results.append({"path": path, "filename": filename, "url": url, "ok": True})
        except Exception as e:
            print(f"[FAIL] {path}: {e}", file=sys.stderr, flush=True)
            results.append({"path": path, "filename": os.path.basename(path), "url": "", "ok": False, "error": str(e)})
    # Print machine-readable summary for downstream parsing
    uploaded = [{"filename": r["filename"], "url": r["url"]} for r in results if r["ok"]]
    if uploaded:
        print(f"UPLOADED_FILES: {json.dumps(uploaded, ensure_ascii=False)}", flush=True)
    return results


def main():
    parser = argparse.ArgumentParser(description="Batch upload local files to OSS")
    parser.add_argument("files", nargs="+", help="List of local file paths")
    args = parser.parse_args()

    [REDACTED_SECRET])
    if not [REDACTED_SECRET]"[error] SKYWORK_API_KEY is required", file=sys.stderr)
        sys.exit(1)

    results = upload_files(args.files, oss_prefix='', [REDACTED_SECRET])

    failed = [r for r in results if not r["ok"]]
    if failed:
        print(f"\n{len(failed)}/{len(results)} file(s) failed to upload", file=sys.stderr)
        sys.exit(1)
    else:
        print(f"\nAll {len(results)} file(s) uploaded successfully")


if __name__ == "__main__":
    main()


path: scripts/parse_file.py
content: #!/usr/bin/env python3
"""
Parse a reference file via the Skywork Office file/parse SSE endpoint.

Uploads the file, triggers server-side analysis (OCR, text extraction),
polls until complete, and returns the parsed content.

Usage:
    python parse_file.py /path/to/document.pdf [--output parsed_content.txt]

Environment variables:
    SKYWORK_API_KEY      - Auth api key
"""

import argparse
import io
import json
import mimetypes
import os
import sys
import uuid
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError

from constant import SKYWORK_GATEWAY_URL
from skywork_auth import get_skywork_api_key


def build_multipart_body(file_path: str):
    """Build a multipart/form-data request body."""
    boundary = f"----FormBoundary{uuid.uuid4().hex[:16]}"
    body = io.BytesIO()
    file_name = os.path.basename(file_path)
    mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"

    body.write(f"--{boundary}\r\n".encode())
    body.write(f'Content-Disposition: form-data; name="file"; filename="{file_name}"\r\n'.encode())
    body.write(f"Content-Type: {mime_type}\r\n\r\n".encode())
    with open(file_path, "rb") as f:
        body.write(f.read())
    body.write(b"\r\n")
    body.write(f"--{boundary}--\r\n".encode())

    return body.getvalue(), f"multipart/form-data; boundary={boundary}"


def parse_sse_events(response):
    """Parse SSE events from an HTTP response stream."""
    cur_event = None
    cur_data = None
    for line_bytes in response:
        line = line_bytes.decode("utf-8", errors="replace").rstrip("\r\n")
        if line == "":
            if cur_event is not None and cur_data is not None:
                try:
                    data = json.loads(cur_data)
                    yield cur_event, data
                except json.JSONDecodeError:
                    pass
            cur_event = None
            cur_data = None
        elif line.startswith("event: "):
            cur_event = line[7:].strip()
        elif line.startswith("data: "):
            cur_data = line[6:]
    if cur_event is not None and cur_data is not None:
        try:
            data = json.loads(cur_data)
            yield cur_event, data
        except json.JSONDecodeError:
            pass


def parse_file(base_url: str, api_key: str, file_path: str) -> dict:
    """Upload and parse a file, streaming progress. Returns parsed metadata."""
    url = f"{base_url}/api/sse/file/parse"
    file_name = os.path.basename(file_path)
    file_size = os.path.getsize(file_path)

    print(f"[parse] File: {file_name} ({file_size:,} bytes)")
    print(f"[parse] Server: {base_url}")
    print()

    body, content_type = build_multipart_body(file_path)
    req = Request(url, data=body, method="POST")
    req.add_header("Content-Type", content_type)
    if [REDACTED_SECRET]"Authorization", f"Bearer {api_key}")

    try:
        resp = urlopen(req, timeout=300)
    except HTTPError as e:
        body_text = e.read().decode('utf-8', errors='replace')
        print(f"[error] HTTP {e.code}: {body_text}", file=sys.stderr)
        sys.exit(1)
    except URLError as e:
        print(f"[error] Cannot reach server: {e.reason}", file=sys.stderr)
        sys.exit(1)

    result = None
    event_count = 0
    for event_type, data in parse_sse_events(resp):
        event_count += 1
        print(f"[debug] Event #{event_count}: {event_type}\n", flush=True)
        if event_type == "progress":
            stage = data.get("stage", "")
            pct = data.get("percentage", 0)
            msg = data.get("message", "")
            bar = "=" * int(pct / 5) + ">" + " " * (20 - int(pct / 5))
            print(f"\r[{bar}] {pct:5.1f}%  {msg}", end="", flush=True)
        elif event_type == "success":
            print()  # newline after progress bar
            result = data
            metadata = data.get("metadata", {})
            exec_time = data.get("execution_time", 0)
            print()
            print(f"[success] File parsed!")
            print(f"  File ID:    {data.get('file_id', '?')}")
            print(f"  File NAME:    {metadata.get('file_name', '')}")
            print(f"  File URL:    {metadata.get('file_url', '')}")
            print(f"  Title:      {metadata.get('parsed_title', '(none)')}")
            print(f"  Summary:    {metadata.get('summary', '(none)')}")
        elif event_type == "error":
            print()
            code = data.get("code", "UNKNOWN")
            msg = data.get("message", "Unknown error")
            print(f"\n[error] {code}: {msg}", file=sys.stderr)
            sys.exit(1)

    if result is None:
        print("[debug] No success/error event received — stream may have been empty or malformed", file=sys.stderr)
    return result


def main():
    parser = argparse.ArgumentParser(description="Parse a reference file via Skywork Office API")
    parser.add_argument("file", help="Path to the file to parse")
    parser.add_argument("--output", "-o", default=None, help="Save parsed content to this file")
    parser.add_argument("--json", action="store_true", help="Output full result as JSON")
    args = parser.parse_args()

    if not os.path.isfile(args.file):
        print(f"[error] File not found: {args.file}", file=sys.stderr)
        sys.exit(1)

    base_url = SKYWORK_GATEWAY_URL
    [REDACTED_SECRET])
    if not [REDACTED_SECRET]"[error] SKYWORK_API_KEY is required", file=sys.stderr)
        sys.exit(1)

    result = parse_file(base_url, api_key, args.file)

    if result:
        metadata = result.get("metadata", {})
        parsed_content = metadata.get("parsed_content", "")

        if args.output:
            with open(args.output, "w", encoding="utf-8") as f:
                f.write(parsed_content)
            print(f"\n[saved] Parsed content written to: {args.output}")
        elif args.json:
            print()
            print(json.dumps(result, indent=2, ensure_ascii=False))
        else:
            # Print a preview of the parsed content
            if parsed_content:
                preview = parsed_content[:1000]
                if len(parsed_content) > 1000:
                    preview += f"\n... ({len(parsed_content) - 1000:,} more chars)"

        # Machine-readable summary for downstream parsing
        file_name = metadata.get("file_name") or os.path.basename(args.file)
        file_url = metadata.get("file_url", "")
        parsed_info = {
            "file_id": result.get("file_id", ""),
            "filename": file_name,
            "file_type": metadata.get("file_type", ""),
            "url": file_url,
        }
        print(f"PARSED_FILE: {json.dumps(parsed_info, ensure_ascii=False)}")


if __name__ == "__main__":
    main()


path: scripts/skywork_auth.py
content: import os
from typing import Optional


def get_skywork_api_key() -> Optional[str]:
    """
    Returns skywork api key.
    """
    [REDACTED_SECRET]"SKYWORK_API_KEY", "")
    if not [REDACTED_SECRET]"SKYWORK_API_KEY is not set.")
        return None
    return api_key

path: scripts/run_editable_convert.py
content: #!/usr/bin/env python3
"""
Call the editable PPTX conversion SSE API, output progress to stdout, and save the generated .pptx locally.
"""
import argparse
import datetime
import json
import os
import sys
import urllib.error
import urllib.request
import uuid

from constant import SKYWORK_GATEWAY_URL
from skywork_auth import get_skywork_api_key


def write_log(log_file: str, line: str):
    try:
        time_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        log_str = f"{time_str} {line.rstrip()}\n"
        print(log_str, flush=True)
        with open(log_file, "a", encoding="utf-8") as f:
            f.write(log_str)
            f.flush()
    except Exception:
        pass


def parse_sse_stream(resp):
    cur_event = None
    cur_data = None
    for line in resp:
        line = line.decode("utf-8", errors="replace").rstrip("\r\n")
        if line == "":
            if cur_event is not None and cur_data is not None:
                try:
                    raw = json.loads(cur_data) if cur_data else {}
                except json.JSONDecodeError:
                    raw = {}
                data = raw.get("data", raw) if isinstance(raw, dict) else raw
                if isinstance(data, str):
                    try:
                        data = json.loads(data)
                    except json.JSONDecodeError:
                        data = {}
                if not isinstance(data, dict):
                    data = {}
                yield cur_event, data
            cur_event = None
            cur_data = None
            continue
        if line.startswith("event:"):
            cur_event = line[6:].strip()
        elif line.startswith("data:"):
            cur_data = line[5:].strip()
    if cur_event is not None and cur_data is not None:
        try:
            raw = json.loads(cur_data) if cur_data else {}
        except json.JSONDecodeError:
            raw = {}
        data = raw.get("data", raw) if isinstance(raw, dict) else raw
        if isinstance(data, str):
            try:
                data = json.loads(data)
            except json.JSONDecodeError:
                data = {}
        if not isinstance(data, dict):
            data = {}
        yield cur_event, data


def phase_to_message(phase: str, data: dict) -> str:
    if phase == "ping":
        progress = data.get("progress", "")
        stage = data.get("stage", "")
        return f"Now {progress}% was done, and is working on {stage}" if stage else f"{progress}%"
    if phase == "get_screenshots":
        status = data.get("status", "")
        if status == "start":
            file_type = data.get("file_type", "")
            return f"Capturing screenshots from {file_type or 'source file'}..."
        if status == "done":
            slide_count = data.get("slide_count", "")
            return f"Screenshots captured, {slide_count} pages total."
        return ""
    if phase == "upload_screenshots":
        status = data.get("status", "")
        if status == "start":
            return "Uploading screenshot images..."
        if status == "done":
            page_count = data.get("page_count", "")
            return f"Screenshot upload complete, {page_count} pages total."
        return ""
    if phase == "screenshot_uploaded":
        page_num = data.get("page_num", "")
        return f"Screenshot {page_num} uploaded."
    if phase == "build_nano_html":
        status = data.get("status", "")
        if status == "start":
            return "Building nano HTML..."
        if status == "done":
            page_count = data.get("page_count", "")
            return f"Nano HTML built, {page_count} pages total."
        return ""
    if phase == "nano_html_page":
        page_num = data.get("page_num", "")
        return f"Nano HTML ready for page {page_num}."
    if phase == "convert_html":
        status = data.get("status", "")
        if status == "start":
            return "Converting HTML..."
        if status == "done":
            page_count = data.get("page_count", "")
            return f"HTML conversion complete, {page_count} pages total."
        return ""
    if phase == "convert_batch":
        batch_index = data.get("batch_index", "")
        batch_count = data.get("batch_count", "")
        return f"Converted batch {batch_index}/{batch_count}."
    if phase == "export":
        status = data.get("status", "")
        if status == "start":
            return "Exporting editable PPTX..."
        if status == "done":
            return "Export complete."
        return ""
    if phase == "done":
        return "Conversion complete, saving file..."
    if phase:
        return f"[{phase}]"
    return ""


def main():
    parser = argparse.ArgumentParser(description="Convert a PPTX or PDF file to an editable PPTX")
    parser.add_argument("--file-url", required=True, dest="file_url", help="OSS URL of the source pptx/pdf file")
    parser.add_argument("--file-type", default="", dest="file_type", help="Optional file type override: pptx or pdf")
    parser.add_argument("--title", default="", help="Optional output file title used by the backend export")
    parser.add_argument("-o", "--output", default="output_editable.pptx", help="Output .pptx path")
    parser.add_argument("--session_id", default=str(uuid.uuid4()), help="session_id used for the request")
    parser.add_argument("--log_path", default="", dest="log_path", help="A file path to save the progress log")
    args = parser.parse_args()

    session_id = args.session_id.replace("-", "_")
    log_file = f"/tmp/ppt_editable_{session_id}.log" if args.log_path == "" else args.log_path
    open(log_file, "w").close()
    print(f"[LOG-File]: {log_file}", flush=True)

    print(f"[PID] {os.getpid()}", flush=True)
    print(f"[START] mode=editable_convert session={session_id} \nIS about to take 5-10minutes, please wait and check the process log every 5 seconds!", flush=True)
    write_log(log_file, f"[PID] {os.getpid()}")
    write_log(log_file, f"[START] mode=editable_convert session={session_id} \nIS about to take 5-10minutes, please wait and check the process log every 5 seconds!")

    payload = {"file_url": args.file_url}
    if args.file_type:
        payload["file_type"] = args.file_type
    if args.title:
        payload["title"] = args.title

    [REDACTED_SECRET])
    if not [REDACTED_SECRET]"[error] SKYWORK_API_KEY is required", file=sys.stderr)
        sys.exit(1)

    headers = {
        "Content-Type": "application/json",
        "Accept": "text/event-stream",
        "Session-Id": session_id,
        "Authorization": f"Bearer {api_key}",
    }
    req = urllib.request.Request(
        f"{SKYWORK_GATEWAY_URL}/convert_file_to_editable_pptx",
        data=json.dumps(payload).encode("utf-8"),
        method="POST",
        headers=headers,
    )

    out_abs = os.path.abspath(args.output)
    try:
        with urllib.request.urlopen(req, timeout=600) as resp:
            for event_type, data in parse_sse_stream(resp):
                if not isinstance(data, dict):
                    data = {}
                if event_type == "phase":
                    phase = data.get("phase", "")
                    msg = phase_to_message(phase, data)
                    if msg:
                        tag = "[PROGRESS]" if phase == "ping" else "[PHASE]"
                        write_log(log_file, f"{tag} {msg}")
                elif event_type == "completionEvent":
                    if data.get("phase", "") == "done":
                        download_url = data.get("download_url", "")
                        slide_count = data.get("slide_count", "")
                        output_id = data.get("output_id", "")
                        if slide_count != "":
                            write_log(log_file, f"[PHASE] Editable PPTX contains {slide_count} slides.")
                        if output_id:
                            write_log(log_file, f"[PHASE] output_id={output_id}")
                        if not download_url:
                            write_log(log_file, "[ERROR] No download_url in completionEvent")
                            sys.exit(1)
                        try:
                            req2 = urllib.request.Request(download_url, method="GET")
                            with urllib.request.urlopen(req2, timeout=120) as r:
                                with open(out_abs, "wb") as f:
                                    f.write(r.read())
                            write_log(log_file, f"[DONE] saved={out_abs} download_url={download_url}")
                        except Exception as e:
                            write_log(log_file, f"[ERROR] Download failed: {e}")
                            sys.exit(1)
                elif event_type == "error":
                    err_msg = data.get("message", str(data))
                    write_log(log_file, f"[ERROR] {err_msg}")
                    sys.exit(1)
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")
        write_log(log_file, f"[ERROR] HTTP {e.code}: {body}")
        sys.exit(1)
    except Exception as e:
        write_log(log_file, f"[ERROR] {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/run_ppt_write.py
content: #!/usr/bin/env python3
"""
Call the ppt_write streaming API, output progress to stdout (for skill to relay in conversation), and save to disk via download_url upon completion.
Supports multiple environments: specify Base URL via environment variables or --env.

General usage (independent of skill installation path):
  - Recommended: have the platform execute run_ppt_write.py from the skill root, e.g.: python <skill_root>/run_ppt_write.py "title" -o my.pptx
    When the user types /ppt-write title, the platform resolves the skill root and runs the above command; no need for the user to specify the path.
  - Or call this script directly: python <skill_root>/scripts/call_stream.py "title" -o my.pptx
"""
import argparse
import datetime
import json
import os
import sys
import urllib.request
import urllib.error
import uuid

from constant import SKYWORK_GATEWAY_URL, POD_TYPE

from skywork_auth import get_skywork_api_key

def get_base_url() -> str:
    return SKYWORK_GATEWAY_URL


def write_log(log_file: str, line: str):
    """Append a plain-text line to the log file."""
    try:
        time_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        log_str = f"{time_str} {line.rstrip()}\n"
        print(log_str, flush=True)
        with open(log_file, "a", encoding="utf-8") as f:            
            f.write(log_str)
            f.flush()
    except Exception:
        pass


def phase_to_message(phase: str, data: dict) -> str:
    """Convert phase to a message suitable for conversation output (consistent with SKILL progress output)"""
    if phase == "outline":
        return "Generating outline..."
    if phase == "outline_page":
        # Outline page-by-page streaming output: show page number and content (newlines collapsed to spaces for single-line console display)
        page_num = data.get("page_num") or 0
        content = (data.get("content") or "").strip()
        if isinstance(content, str):
            content = " ".join(content.split())
        else:
            content = str(content) if content else ""
        if content:
            line = f"  Page {page_num}: {content[:300]}{'...' if len(content) > 300 else ''}"
            return line
        return f"  Page {page_num}: (no body text)"
    if phase == "outline_done":
        return "Outline generated successfully!"
    if phase == "slides":
        return "Generating slides..."
    if phase == "slides_page":
        page_num = data.get("page_num") or 0
        return f"Finish generating Page {page_num} "
    if phase == "slides_page_start":
        page_num = data.get("page_num") or 0
        content = " ".join((data.get("content") or "").split())
        snippet = content[:200] + ("..." if len(content) > 200 else "")
        return f"Start generating page {page_num},content: \n{snippet}" if snippet else f"Start generating page {page_num}"
    if phase == "slides_done":
        return "Slides generated, exporting PPTX..."
    if phase == "export":
        status = data.get("status", "")
        if status == "done":
            return "Export complete."
        return "Exporting PPTX... this step takes 2-5 minutes. KEEP reading this log every 5 seconds — do NOT stop polling until you see [DONE] or [ERROR]."
    if phase == "done":
        return "Generation complete, saving file..."
    if phase == "ping":
        progress = data.get("progress", "")
        stage = data.get("stage", "")
        return f"Now {progress}% was done, and is working on {stage}" if stage else f"{progress}%"
    # --- Update/Edit PPT specific phases ---
    if phase == "parse_pptx":
        status = data.get("status", "")
        if status == "start":
            return "Parsing existing PPTX..."
        if status == "done":
            count = data.get("original_pptx_slide_count", "")
            return f"PPTX parsed, {count} slides total."
        return ""
    if phase == "gen_update_plan":
        status = data.get("status", "")
        if status == "start":
            return "Generating update plan..."
        if status == "done":
            update_count = data.get("update_pptx_slide_count", "")
            new_count = data.get("new_slide_count", 0)
            return f"Update plan ready: {update_count} slides to update, {new_count} new slides to generate."
        return ""
    if phase == "gen_new_slides":
        status = data.get("status", "")
        if status == "start":
            return "Generating updated slides..."
        if status == "done":
            count = data.get("generated_new_slide_count", "")
            return f"Updated slides generated ({count} slides)."
        page_num = data.get("page_num")
        if page_num is not None:
            return f"Slide {page_num} updated."
        return ""
    if phase == "export_update_pptx":
        status = data.get("status", "")
        if status == "start":
            return "Exporting updated PPTX..."
        if status == "done":
            count = data.get("generated_update_pptx_slide_count", "")
            return f"Export complete, {count} slides."
        return ""
    # --- Template-based generation specific phases ---
    if phase == "parse_template":
        status = data.get("status", "")
        if status == "start":
            return "Parsing template..."
        if status == "done":
            slide_count = data.get("slide_count", "")
            return f"Template parsing complete, {slide_count} pages in total."
        return ""
    if phase == "gen_outline":
        status = data.get("status", "")
        if status == "start":
            return "Generating outline..."
        if status == "done":
            page_count = data.get("page_count", "")
            return f"Outline generated, {page_count} pages in total."
        return ""
    if phase == "assign_templates":
        status = data.get("status", "")
        page_count = data.get("page_count", "")
        if status == "start":
            return "Assigning templates to each page..."
        if status == "done":
            return f"Template assignment complete, {page_count} pages in total."
        return ""
    if phase == "fill_content":
        status = data.get("status", "")
        page_count = data.get("page_count", "")
        if status == "start":
            return "Filling in content..."
        if status == "done":
            return f"Content filled, {page_count} pages in total."
        return ""
    if phase == "gen_images":
        status = data.get("status", "")
        if status == "start":
            return "Generating images..."
        if status == "done":
            return "Image generation complete."
        return ""
    if phase == "slide_image":
        page_num = data.get("page_num", "")
        return f"Page {page_num} image generated."
    if phase == "convert_html":
        status = data.get("status", "")
        if status == "start":
            return "Converting HTML..."
        if status == "done":
            page_count = data.get("page_count", "")
            return f"HTML conversion complete, {page_count} pages in total."
        return ""
    if phase:
        return f"[{phase}]"
    return "..."


def parse_sse_stream(resp):
    """Parse SSE stream, yield (event_type, data_dict).
    The backend SendEventData wraps data as {"code":0,"message":"success","data": payload}; the data field needs to be extracted.
    """
    cur_event = None
    cur_data = None
    for line in resp:
        line = line.decode("utf-8", errors="replace").rstrip("\r\n")
        if line == "":
            if cur_event is not None and cur_data is not None:
                try:
                    raw = json.loads(cur_data) if cur_data else {}
                except json.JSONDecodeError:
                    raw = {}
                # Compatible with backend wrapper format: {"code":0,"message":"success","data": {...}}; data may also be a JSON string
                data = raw.get("data", raw) if isinstance(raw, dict) else raw
                if isinstance(data, str):
                    try:
                        data = json.loads(data)
                    except json.JSONDecodeError:
                        data = {}
                if not isinstance(data, dict):
                    data = {}
                yield cur_event, data
            cur_event = None
            cur_data = None
            continue
        if line.startswith("event:"):
            cur_event = line[6:].strip()
        elif line.startswith("data:"):
            cur_data = line[5:].strip()
    if cur_event is not None and cur_data is not None:
        try:
            raw = json.loads(cur_data) if cur_data else {}
        except json.JSONDecodeError:
            raw = {}
        data = raw.get("data", raw) if isinstance(raw, dict) else raw
        if isinstance(data, str):
            try:
                data = json.loads(data)
            except json.JSONDecodeError:
                data = {}
        if not isinstance(data, dict):
            data = {}
        yield cur_event, data


def main():
    parser = argparse.ArgumentParser(description="Call ppt_write stream API and save .pptx")
    parser.add_argument("query", nargs="?", default="", help="User query / PPT topic")
    parser.add_argument("--language", default="en", help="Language used to generate slides. e.g. English, Chinese, etc.")
    parser.add_argument("--reference", default="", help="Reference content for enriching slide content, e.g. web search results summary.")
    parser.add_argument("--reference-file", default="", dest="reference_file", help="Path to a local file whose content will be used as reference (preferred over --reference to avoid shell encoding issues).")
    parser.add_argument("-o", "--output", default="output.pptx", help="Output .pptx path")
    parser.add_argument("--session_id", default=str(uuid.uuid4()), help="session_id used for distinct ppt generate query")
    parser.add_argument("--template_urls", default="", help="Comma-separated list of template PPTX OSS URLs for style imitation")
    parser.add_argument("--files", default="", help='Reference files JSON from upload_files.py UPLOADED_FILES output, format: [{"filename":"a.pdf","url":"https://..."}]')
    parser.add_argument("--pptx-url", default="", dest="pptx_url", help="OSS URL of an existing PPTX to edit (triggers update/edit mode instead of generation).")
    parser.add_argument("--log_path", default="", dest="log_path", help="A file path to save the progress log.")
    args = parser.parse_args()
    query = args.query.strip()
    if not query:
        parser.error("query is required")
        return

    base_url = get_base_url()
    payload = {"query": query, "language": args.language}

    session_id = args.session_id.replace('-', '_')
    log_file = f"/tmp/ppt_run_{session_id}.log" if args.log_path == "" else args.log_path
    open(log_file, "w").close()  # clear previous run
    print(f"[LOG-File]: {log_file}", flush=True)

    # Determine endpoint and mode
    if args.pptx_url:
        url = f"{base_url}/update_pptx_process"
        payload["pptx_url"] = args.pptx_url
        mode = "editing"
    elif args.template_urls:
        url = f"{base_url}/chat_pptx_process"
        payload["template_urls"] = [u.strip() for u in args.template_urls.split(",") if u.strip()]
        mode = "imitation"
    else:
        url = f"{base_url}/ppt_write_stream"
        mode = "generating"

    print(f"[PID] {os.getpid()}", flush=True)
    print(f"[START] mode={mode} session={session_id} \nIS about to take 5-10minutes, please wait and check the process log every 5 seconds!", flush=True)
    write_log(log_file, f"[PID] {os.getpid()}")
    write_log(log_file, f"[START] mode={mode} session={session_id} \nIS about to take 5-10minutes, please wait and check the process log every 5 seconds!")

    reference = args.reference
    if args.reference_file:
        try:
            with open(args.reference_file, "r", encoding="utf-8") as f:
                reference = f.read()
        except Exception as e:
            print(f"[error] Failed to read reference file: {e}", file=sys.stderr)
            sys.exit(1)
    if reference:
        payload["reference"] = reference
    if args.files:
        try:
            payload["files"] = json.loads(args.files)
        except json.JSONDecodeError as e:
            print(f"--files JSON parse error: {e}", file=sys.stderr)
            sys.exit(1)
    payload["source_platform"] = "skyclaw" if POD_TYPE == "skyclaw" else ""
    body = json.dumps(payload).encode("utf-8")
    headers = {
        "Content-Type": "application/json",
        "Accept": "text/event-stream",
        "Session-Id": session_id,
        "Language": args.language,
    }
    [REDACTED_SECRET])
    if not [REDACTED_SECRET]"[error] SKYWORK_API_KEY is required", file=sys.stderr)
        sys.exit(1)
    headers["Authorization"] = f"Bearer {api_key}"

    req = urllib.request.Request(
        url,
        data=body,
        method="POST",
        headers=headers,
    )

    out_abs = os.path.abspath(args.output)
    try:
        with urllib.request.urlopen(req, timeout=600) as resp:
            for event_type, data in parse_sse_stream(resp):
                if not isinstance(data, dict):
                    data = {}
                if event_type == "phase":
                    phase = data.get("phase", "")
                    msg = phase_to_message(phase, data)
                    if msg:
                        tag = "[PROGRESS]" if phase == "ping" else "[PHASE]"
                        write_log(log_file, f"{tag} {msg}")
                    if phase == "outline_done":
                        outline = data.get("outline", "")
                        if outline:
                            write_log(log_file, f"[OUTLINE]\n{outline}")
                elif event_type == "completionEvent":
                    phase = data.get("phase", "")
                    if phase == "done":
                        download_url = data.get("download_url")
                        write_log(log_file, "[PHASE] Generation complete, saving file to local disk. This may take 1 or 2 minutes, we are already success!")
                        if not download_url:
                            write_log(log_file, "[ERROR] No download_url in completionEvent")
                            sys.exit(1)
                        try:
                            req2 = urllib.request.Request(download_url, method="GET")
                            with urllib.request.urlopen(req2, timeout=120) as r:
                                with open(out_abs, "wb") as f:
                                    f.write(r.read())
                            write_log(log_file, f"[DONE] saved={out_abs} download_url={download_url}")
                        except Exception as e:
                            write_log(log_file, f"[ERROR] Download failed: {e}")
                            sys.exit(1)
                elif event_type == "error":
                    err_msg = data.get("message", str(data))
                    write_log(log_file, f"[ERROR] {err_msg}")
                    sys.exit(1)
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")
        write_log(log_file, f"[ERROR] HTTP {e.code}: {body}")
        sys.exit(1)
    except Exception as e:
        write_log(log_file, f"[ERROR] {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/constant.py
content: SKYWORK_GATEWAY_URL = "https://api-tools.skywork.ai/theme-gateway"
POD_TYPE = ""


path: scripts/web_search.py
content: #!/usr/bin/env python3
"""
web_search.py - Call local web search API and print results.

Usage:
    python web_search.py "query1" ["query2" ...]
"""

import argparse
import json
import os
import sys
import tempfile
import urllib.request

from constant import SKYWORK_GATEWAY_URL, POD_TYPE
from skywork_auth import get_skywork_api_key

def search(query: str, api_key: str) -> str:
    """Call web_search API and return formatted text of results."""
    url = f"{SKYWORK_GATEWAY_URL}/web_search"
    payload = {"query": query, "source_platform": "skyclaw" if POD_TYPE == "skyclaw" else ""}
    body = json.dumps(payload).encode("utf-8")
    headers = {"Content-Type": "application/json"}
    if [REDACTED_SECRET]"Authorization"] = f"Bearer {api_key}"
    req = urllib.request.Request(
        url,
        data=body,
        method="POST",
        headers=headers,
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            raw = resp.read().decode("utf-8")
    except urllib.error.URLError as e:
        print(f"[ERROR] Error call web_search, try only 1 time more: {e}", file=sys.stderr)
        sys.exit(1)

    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return raw  # fallback: return raw if not JSON

    results = data.get("search_res", [])
    if not results:
        return "(no results)"

    lines = []
    for i, item in enumerate(results, 1):
        content = (item.get("content") or "").strip()
        source_url = item.get("url", "")
        lines.append(f"[result-{i}] {source_url}\n{content}")
    return "\n\n".join(lines)


def main():
    parser = argparse.ArgumentParser(description="Call local web_search API")
    parser.add_argument("queries", nargs="+", help="One or more search queries (max 3)")
    args = parser.parse_args()

    queries = args.queries[:3]
    out_dir = tempfile.mkdtemp(prefix="web_search_")

    [REDACTED_SECRET])
    if not [REDACTED_SECRET]"[error] SKYWORK_API_KEY is required", file=sys.stderr)
        sys.exit(1)

    for i, q in enumerate(queries, 1):
        print(f"[query] {q} ...", file=sys.stderr, flush=True)
        raw = search(q, api_key)
        out_path = os.path.join(out_dir, f"{q}_result.txt")
        with open(out_path, "w", encoding="utf-8") as f:
            f.write(f"query: {q}\n\n{raw}")
            print(f'Already saved search result for query[{q}], \nout_path: {out_path}', flush=True)


if __name__ == "__main__":
    main()


path: scripts/local_pptx_ops.py
content: #!/usr/bin/env python3
"""
Local PPTX operation tool (Layer 3, no backend required).
Dependency: pip install python-pptx

Subcommands:
  info     View basic file info (slide count, slide titles)
  delete   Delete specified slides (1-based, supports multiple and ranges, e.g. 3,5,7-9)
  reorder  Reorder slide sequence (e.g. 2,1,4,3,5)
  extract  Extract selected slides into a new file
  merge    Merge multiple pptx files

Usage examples:
  python scripts/local_pptx_ops.py info --file my.pptx
  python scripts/local_pptx_ops.py delete --file my.pptx --slides 3,5,7-9
  python scripts/local_pptx_ops.py delete --file my.pptx --slides 3,5,7-9 -o trimmed.pptx
  python scripts/local_pptx_ops.py reorder --file my.pptx --order 2,1,4,3,5
  python scripts/local_pptx_ops.py extract --file my.pptx --slides 1-3 -o subset.pptx
  python scripts/local_pptx_ops.py merge --files a.pptx b.pptx -o merged.pptx
"""

import argparse
import os
import sys
import shutil
import copy
import warnings

try:
    from pptx import Presentation
    from pptx.util import Inches, Pt
    from lxml import etree
except ImportError:
    print("Missing dependency, please run: pip install python-pptx", file=sys.stderr)
    sys.exit(1)


# ---------------------------------------------------------------------------
# Parse slide number string (1-based), return 0-based index list
# ---------------------------------------------------------------------------

def parse_slide_spec(spec: str, total: int) -> list[int]:
    """Parse a slide number string like '1,3,5-8,10', return a deduplicated sorted list of 0-based indices."""
    indices = set()
    for part in spec.split(","):
        part = part.strip()
        if not part:
            continue
        if "-" in part:
            lo, hi = part.split("-", 1)
            lo_i, hi_i = int(lo.strip()), int(hi.strip())
            if lo_i < 1 or hi_i > total:
                raise ValueError(f"Slide range {lo_i}-{hi_i} is out of bounds for a file with {total} slides")
            for i in range(lo_i - 1, hi_i):
                indices.add(i)
        else:
            n = int(part)
            if n < 1 or n > total:
                raise ValueError(f"Slide number {n} is out of bounds for a file with {total} slides")
            indices.add(n - 1)
    return sorted(indices)


def parse_order_spec(spec: str, total: int) -> list[int]:
    """Parse an order string for reorder, preserving user-specified order, return 0-based index list."""
    result = []
    seen = set()
    for part in spec.split(","):
        part = part.strip()
        if not part:
            continue
        n = int(part)
        if n < 1 or n > total:
            raise ValueError(f"Slide number {n} is out of bounds for a file with {total} slides")
        idx = n - 1
        if idx in seen:
            raise ValueError(f"Slide number {n} appears more than once")
        seen.add(idx)
        result.append(idx)
    return result


# ---------------------------------------------------------------------------
# Core operation: remove a single slide (XML manipulation, not natively supported by python-pptx)
# ---------------------------------------------------------------------------

def _remove_slide(prs: "Presentation", index: int) -> None:
    """Remove the slide at the given index (0-based) from the Presentation."""
    xml_slides = prs.slides._sldIdLst
    slide_elem = xml_slides[index]
    rId = slide_elem.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id")
    xml_slides.remove(slide_elem)
    # Remove the corresponding relationship from rels to avoid "Duplicate name" warnings on save
    prs.part.rels.pop(rId)


# ---------------------------------------------------------------------------
# Subcommand: info
# ---------------------------------------------------------------------------

def cmd_info(args):
    prs = Presentation(args.file)
    total = len(prs.slides)
    print(f"File: {os.path.abspath(args.file)}")
    print(f"Total slides: {total}")
    print(f"Slide dimensions: {prs.slide_width.inches:.1f}\" x {prs.slide_height.inches:.1f}\"")
    print()
    for i, slide in enumerate(prs.slides):
        title_text = ""
        for shape in slide.shapes:
            if shape.has_text_frame and shape.shape_type == 13:
                continue  # Skip images
            try:
                if hasattr(shape, "placeholder_format") and shape.placeholder_format is not None:
                    ph_idx = shape.placeholder_format.idx
                    if ph_idx == 0:  # Title placeholder
                        title_text = shape.text_frame.text.strip()
                        break
            except Exception:
                pass
        # Fallback: use the first shape that has text
        if not title_text:
            for shape in slide.shapes:
                if shape.has_text_frame:
                    t = shape.text_frame.text.strip()
                    if t:
                        title_text = t[:60]
                        break
        print(f"  Slide {i+1:2d}: {title_text or '(no title)'}")


# ---------------------------------------------------------------------------
# Subcommand: delete
# ---------------------------------------------------------------------------

def cmd_delete(args):
    prs = Presentation(args.file)
    total = len(prs.slides)
    indices = parse_slide_spec(args.slides, total)
    if not indices:
        print("No valid slide numbers specified, no action taken.")
        return

    # Delete from back to front to avoid index shifting
    for idx in reversed(indices):
        _remove_slide(prs, idx)

    pages_str = ", ".join(str(i + 1) for i in indices)
    remaining = total - len(indices)
    out = _resolve_output(args, args.file)
    prs.save(out)
    print(f"Deleted slide(s) {pages_str} ({len(indices)} deleted, {remaining} remaining)")
    print(f"Saved to: {os.path.abspath(out)}")
    print()
    print(f"RESULT: success")
    print(f"OUTPUT_FILE: {os.path.abspath(out)}")


# ---------------------------------------------------------------------------
# Subcommand: reorder
# ---------------------------------------------------------------------------

def cmd_reorder(args):
    prs = Presentation(args.file)
    total = len(prs.slides)
    order = parse_order_spec(args.order, total)

    if len(order) != total:
        raise ValueError(
            f"--order specified {len(order)} slide numbers, but the file has {total} slides; all slides must be included without duplicates."
        )
    if sorted(order) != list(range(total)):
        raise ValueError("--order must include each slide exactly once (no duplicates, no omissions).")

    xml_slides = prs.slides._sldIdLst
    # Retrieve all sldId elements
    sld_id_elems = list(xml_slides)
    # Rearrange in the new order
    for elem in sld_id_elems:
        xml_slides.remove(elem)
    for new_pos in order:
        xml_slides.append(sld_id_elems[new_pos])

    out = _resolve_output(args, args.file)
    prs.save(out)
    order_str = ",".join(str(i + 1) for i in order)
    print(f"Reordered to [{order_str}], total {total} slides")
    print(f"Saved to: {os.path.abspath(out)}")
    print()
    print(f"RESULT: success")
    print(f"OUTPUT_FILE: {os.path.abspath(out)}")


# ---------------------------------------------------------------------------
# Subcommand: extract
# ---------------------------------------------------------------------------

def cmd_extract(args):
    prs = Presentation(args.file)
    total = len(prs.slides)
    keep = parse_slide_spec(args.slides, total)
    if not keep:
        print("No valid slide numbers specified, no action taken.")
        return

    # Delete slides not in the keep list
    delete_indices = [i for i in range(total) if i not in set(keep)]
    for idx in reversed(delete_indices):
        _remove_slide(prs, idx)

    out = args.output or "extracted.pptx"
    prs.save(out)
    pages_str = ", ".join(str(i + 1) for i in keep)
    print(f"Extracted slide(s) {pages_str} ({len(keep)} slides)")
    print(f"Saved to: {os.path.abspath(out)}")
    print()
    print(f"RESULT: success")
    print(f"OUTPUT_FILE: {os.path.abspath(out)}")


# ---------------------------------------------------------------------------
# Subcommand: merge
# ---------------------------------------------------------------------------

def cmd_merge(args):
    if not args.files or len(args.files) < 2:
        raise ValueError("At least two --files must be provided.")

    # Use the first file as the base
    base_prs = Presentation(args.files[0])
    slide_width = base_prs.slide_width
    slide_height = base_prs.slide_height

    for src_path in args.files[1:]:
        src_prs = Presentation(src_path)
        for slide in src_prs.slides:
            # Add a new slide with a blank layout
            slide_layout = base_prs.slide_layouts[-1]  # Use the last layout (usually blank)
            new_slide = base_prs.slides.add_slide(slide_layout)
            # Copy the spTree (shape tree) contents from the source slide
            new_sp_tree = new_slide.shapes._spTree
            old_sp_tree = slide.shapes._spTree
            # Remove default placeholders from the new slide
            for child in list(new_sp_tree):
                new_sp_tree.remove(child)
            # Copy all child elements
            for child in old_sp_tree:
                new_sp_tree.append(copy.deepcopy(child))

    out = args.output or "merged.pptx"
    base_prs.save(out)
    total = len(base_prs.slides)
    files_str = ", ".join(args.files)
    print(f"Merged: {files_str}")
    print(f"Total {total} slides, saved to: {os.path.abspath(out)}")
    print()
    print(f"RESULT: success")
    print(f"OUTPUT_FILE: {os.path.abspath(out)}")


# ---------------------------------------------------------------------------
# Utility functions
# ---------------------------------------------------------------------------

def _resolve_output(args, original_file: str) -> str:
    """Use -o output path if specified by the user, otherwise overwrite the original file."""
    if hasattr(args, "output") and args.output:
        return args.output
    return original_file


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description="Local PPTX operation tool (delete/reorder/extract/merge slides)",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python scripts/local_pptx_ops.py info --file my.pptx
  python scripts/local_pptx_ops.py delete --file my.pptx --slides 3,5,7-9
  python scripts/local_pptx_ops.py delete --file my.pptx --slides 3,5,7-9 -o trimmed.pptx
  python scripts/local_pptx_ops.py reorder --file my.pptx --order 2,1,4,3,5
  python scripts/local_pptx_ops.py extract --file my.pptx --slides 1-3 -o subset.pptx
  python scripts/local_pptx_ops.py merge --files a.pptx b.pptx -o merged.pptx
""",
    )
    sub = parser.add_subparsers(dest="cmd", required=True)

    # info
    p_info = sub.add_parser("info", help="View file info (slide count, titles)")
    p_info.add_argument("--file", required=True, help="Input .pptx file path")

    # delete
    p_del = sub.add_parser("delete", help="Delete specified slides (1-based, supports '3,5,7-9')")
    p_del.add_argument("--file", required=True, help="Input .pptx file path")
    p_del.add_argument("--slides", required=True, help="Slide numbers to delete, e.g. '3,5,7-9'")
    p_del.add_argument("-o", "--output", default="", help="Output path (defaults to overwriting the original file)")

    # reorder
    p_reorder = sub.add_parser("reorder", help="Reorder slide sequence")
    p_reorder.add_argument("--file", required=True, help="Input .pptx file path")
    p_reorder.add_argument("--order", required=True, help="New order, e.g. '2,1,4,3,5' (must include all slides)")
    p_reorder.add_argument("-o", "--output", default="", help="Output path (defaults to overwriting the original file)")

    # extract
    p_extract = sub.add_parser("extract", help="Extract selected slides into a new file")
    p_extract.add_argument("--file", required=True, help="Input .pptx file path")
    p_extract.add_argument("--slides", required=True, help="Slide numbers to keep, e.g. '1-3,5'")
    p_extract.add_argument("-o", "--output", default="", help="Output path (defaults to extracted.pptx)")

    # merge
    p_merge = sub.add_parser("merge", help="Merge multiple pptx files")
    p_merge.add_argument("--files", nargs="+", required=True, help="Input file list (merged in order)")
    p_merge.add_argument("-o", "--output", default="", help="Output path (defaults to merged.pptx)")

    args = parser.parse_args()

    try:
        with warnings.catch_warnings():
            warnings.filterwarnings("ignore", message="Duplicate name", category=UserWarning)
            if args.cmd == "info":
                cmd_info(args)
            elif args.cmd == "delete":
                cmd_delete(args)
            elif args.cmd == "reorder":
                cmd_reorder(args)
            elif args.cmd == "extract":
                cmd_extract(args)
            elif args.cmd == "merge":
                cmd_merge(args)
    except (ValueError, FileNotFoundError) as e:
        print(f"Error: {e}", file=sys.stderr)
        print()
        print(f"RESULT: error")
        print(f"ERROR: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: references/apikey-fetch.md
content: # Skywork API Key Setup Guide

## SKYWORK_API_KEY Not Configured

When the `SKYWORK_API_KEY` environment variable is not set, follow these steps:

### 1. Get API Key

Visit the Skywork website and sign in to your account:

**https://skywork.ai**

- Log in with your Skywork account
- Open account / Settings / API Key (**https://skywork.ai/?openApiKeySetting=1**)
- Create or copy your **API key**

If your organization uses a separate console or test environment, use the URL and credentials your team provides.

### 2. Configure OpenClaw

Edit the OpenClaw configuration file: `~/.openclaw/openclaw.json`

In current OpenClaw, Skywork skills store the key under `skills.entries.<Skill Name>.apiKey` (not under `env`).
OpenClaw will inject this value into the skill's `SKYWORK_API_KEY` environment when `primaryEnv` matches.
Add or merge the following structure (adjust the skill name to match the installed skill):

```json
{
  "skills": {
    "entries": {
      "Skywork-ppt": {
        "enabled": true,
        "apiKey": "your_actual_skywork_api_key_here"
      }
    }
  }
}
```

Replace `"your_actual_skywork_api_key_here"` with your real key.

For multiple Skywork skills, repeat the same `apiKey` field on each skill entry.

### 3. Configure Claude Code

If you are using Claude Code, use one of these lightweight options:

**Option A — shell environment**

Export the API key before running the skill:

```bash
export SKYWORK_API_KEY="your_actual_skywork_api_key_here"
```

To persist it across sessions, add the same line to `~/.zshrc` or `~/.bashrc`, then reload the shell.

**Option B — Claude Code settings**

Add the variable to `~/.claude/settings.json`:

```json
{
  "env": {
    "SKYWORK_API_KEY": "your_actual_skywork_api_key_here"
  }
}
```

Use the method that best matches how you run Claude Code.

### 4. Verify Configuration

```bash
# Check that the environment variable is available
echo "$SKYWORK_API_KEY"
```

For OpenClaw, you can also validate the config file:

```bash
cat ~/.openclaw/openclaw.json | python3 -m json.tool
```

### 5. Restart OpenClaw

```bash
openclaw gateway restart
```

## Troubleshooting

- Ensure `~/.openclaw/openclaw.json` exists and is valid JSON
- Ensure `SKYWORK_API_KEY` is available in Claude Code through your shell or `~/.claude/settings.json`
- Confirm the API key is active and not expired
- Check Skywork account status, membership, or quota if requests fail with auth or benefit errors
- Restart OpenClaw after configuration changes

**Recommended**: Use the setup method that matches your runtime. OpenClaw should use the OpenClaw config file; Claude Code can use either the shell environment or `~/.claude/settings.json`.


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