references/articles.md
# X Articles - Detailed Guide
Publish Markdown articles to X Articles editor with rich text formatting and images.
## Mode Selection
In Codex, choose the browser-control mode from the user's wording:
1. If the user says "Codex Chrome plugin", "Codex 自带的 Chrome 插件", `@chrome`, or Chrome Extension, use **Codex Chrome Plugin Workflow**. Do not try Computer Use first.
2. If the user explicitly asks for Chrome Computer Use, use **Computer Use Workflow**.
3. If the user explicitly asks for CDP/script mode, use **CDP Script Workflow**.
4. Otherwise, use Computer Use when available; if unavailable or blocked, use CDP Script Workflow.
Never use the in-app Browser for X Article publishing. Never switch away from an explicitly requested mode without explaining the blocker and getting approval.
## Prerequisites
- X Premium subscription (required for Articles)
- Google Chrome installed
- `bun` installed
## Usage
### Codex Chrome Plugin (When Requested)
Use the `chrome:Chrome` skill and its Node REPL browser client. Verify the connection with a lightweight call such as `browser.user.openTabs()`. If it fails, wait 2 seconds and retry once, then follow the Chrome skill's health checks.
Prepare the article HTML and image map:
```bash
${BUN_X} {baseDir}/scripts/md-to-html.ts article.md --save-html /tmp/x-article-body.html > /tmp/x-article.json
```
Copy generated HTML as rich text:
```bash
${BUN_X} {baseDir}/scripts/copy-to-clipboard.ts html --file /tmp/x-article-body.html
```
Use the Chrome plugin's tab, Playwright-wrapper, CUA, clipboard, and file chooser APIs for all X UI operations. If upload fails with `Not allowed`, stop and tell the user to enable file URL access for the Codex Chrome Extension in `chrome://extensions` → Details.
### Chrome Computer Use
Prepare the article HTML and image map:
```bash
${BUN_X} {baseDir}/scripts/md-to-html.ts article.md --save-html /tmp/x-article-body.html > /tmp/x-article.json
```
Copy the generated HTML as rich text:
```bash
${BUN_X} {baseDir}/scripts/copy-to-clipboard.ts html --file /tmp/x-article-body.html
```
Then use Codex Computer Use against `Google Chrome` for all X UI operations.
### CDP Script Fallback
```bash
# Publish markdown article (preview mode)
${BUN_X} {baseDir}/scripts/x-article.ts article.md
# With custom cover image
${BUN_X} {baseDir}/scripts/x-article.ts article.md --cover ./cover.jpg
# Actually publish
${BUN_X} {baseDir}/scripts/x-article.ts article.md --submit
```
Do not use `--submit` unless the user has explicitly confirmed the final public publish action.
## Markdown Format
```markdown
---
title: My Article Title
cover_image: /path/to/cover.jpg
---
# Title (becomes article title)
Regular paragraph text with **bold** and *italic*.
## Section Header
More content here.

- List item 1
- List item 2
1. Numbered item
2. Another item
> Blockquote text
[Link text](https://example.com)
\`\`\`
Code blocks become blockquotes (X doesn't support code)
\`\`\`
```
## Frontmatter Fields
| Field | Description |
|-------|-------------|
| `title` | Article title (or uses first H1) |
| `cover_image` | Cover image path or URL |
| `cover` | Alias for cover_image |
| `image` | Alias for cover_image |
## Image Handling
1. **Cover Image**: First image or `cover_image` from frontmatter
2. **Remote Images**: Automatically downloaded to temp directory
3. **Placeholders**: Images in content use `XIMGPH_N` format
4. **Insertion**: Placeholders are found, selected, and replaced with actual images
## Markdown to HTML Script
Convert markdown and inspect structure:
```bash
# Get JSON with all metadata
${BUN_X} {baseDir}/scripts/md-to-html.ts article.md
# Output HTML only
${BUN_X} {baseDir}/scripts/md-to-html.ts article.md --html-only
# Save HTML to file
${BUN_X} {baseDir}/scripts/md-to-html.ts article.md --save-html /tmp/article.html
```
JSON output:
```json
{
"title": "Article Title",
"coverImage": "/path/to/cover.jpg",
"contentImages": [
{
"placeholder": "XIMGPH_1",
"localPath": "/tmp/x-article-images/img.png",
"blockIndex": 5
}
],
"html": "<p>Content...</p>",
"totalBlocks": 20
}
```
## Supported Formatting
| Markdown | HTML Output |
|----------|-------------|
| `# H1` | Title only (not in body) |
| `## H2` - `###### H6` | `<h2>` |
| `**bold**` | `<strong>` |
| `*italic*` | `<em>` |
| `[text](url)` | `<a href>` |
| `> quote` | `<blockquote>` |
| `` `code` `` | `<code>` |
| ```` ``` ```` | `<blockquote>` (X limitation) |
| `- item` | `<ul><li>` |
| `1. item` | `<ol><li>` |
| `` | Image placeholder |
## Codex Chrome Plugin Workflow
1. **Load Chrome skill**: use `chrome:Chrome`, not Computer Use.
2. **Connect**: initialize the Chrome plugin browser client and verify with `browser.user.openTabs()`.
3. **Parse Markdown**: run `md-to-html.ts --save-html /tmp/x-article-body.html > /tmp/x-article.json`.
4. **Read the map**: use `/tmp/x-article.json` for `title`, `coverImage`, and `contentImages`.
5. **Open X Articles**: open or claim a Chrome tab for `https://x.com/compose/articles`.
6. **Create Draft**: click the create/write button if needed, or open the target draft.
7. **Upload Cover**: use the Chrome plugin file chooser flow. If file upload returns `Not allowed`, report the Chrome Extension file-access fix and stop.
8. **Fill Title**: fill the title field.
9. **Paste Content**:
- Run `copy-to-clipboard.ts html --file /tmp/x-article-body.html`.
- Click the article body.
- Press `Meta+V` on macOS or `Control+V` on Windows/Linux through the Chrome plugin.
- Verify the article body appeared and contains `XIMGPH_` placeholders. On macOS, use `pbpaste` to verify shell-written system clipboard contents if paste is suspicious; `tab.clipboard.readText()` may not reflect the system clipboard after shell writes.
10. **Insert Images**: for each `contentImages` item in placeholder order:
- Locate the exact visible placeholder text (`XIMGPH_N`) and click it to put the insertion point there.
- Open the editor toolbar dropdown `Insert` and choose `Media`.
- In the `Insert` modal, click the icon button with `aria-label="Add photos or video"`; do not click the "Choose a file or drag it here" text/dropzone or hidden file input.
- Use the Chrome plugin file chooser flow to upload that image's `localPath`.
- Wait until the image block appears. If `XIMGPH_N` remains above the image, select exactly that placeholder and press `Delete` first; use `Backspace` only if `Delete` fails and the selected text is confirmed to be exactly the placeholder.
- Verify that placeholder's count is `0` before continuing.
11. **Verify**:
- Inspect the editor for `XIMGPH_` residue.
- Confirm the expected number of image blocks is visible.
- Open Preview and verify title, cover, body, links, and images.
12. **Publish Safety**: ask the user for explicit final confirmation before clicking `Publish`.
If the Chrome plugin reports `native pipe is closed`, retry one lightweight browser call after 2 seconds, then run the Chrome skill health checks. If Chrome, the extension, and native host are healthy, ask the user before opening a new Chrome window and retrying.
## Computer Use Workflow
1. **Detect Computer Use**: call `get_app_state` for `Google Chrome`; use `tool_search` first if the tools are not visible.
2. **Parse Markdown**: run `md-to-html.ts --save-html /tmp/x-article-body.html > /tmp/x-article.json`.
3. **Read the map**: use `/tmp/x-article.json` for `title`, `coverImage`, and `contentImages`.
4. **Open X Articles**: use Chrome Computer Use to navigate to `https://x.com/compose/articles`.
5. **Create Draft**: click the create/write button if needed, or open the target draft.
6. **Upload Cover**: if `coverImage` exists, use Chrome's visible upload/file picker UI. If the file picker cannot be operated reliably, stop and ask for help rather than switching to CDP silently.
7. **Fill Title**: type the title into the title field.
8. **Paste Content**:
- Run `copy-to-clipboard.ts html --file /tmp/x-article-body.html`.
- Click the article body.
- Press `super+v` on macOS or `control+v` on Windows/Linux with Computer Use.
9. **Insert Images**: for each `contentImages` item in placeholder order:
- Locate the exact visible placeholder text (`XIMGPH_N`) and click it to put the insertion point there.
- Open the editor toolbar dropdown `Insert`, choose `Media`, then click the icon button with `aria-label="Add photos or video"` inside the modal.
- Use the native file picker to choose that image's `localPath`.
- Wait until the image block appears and upload activity is complete.
- If `XIMGPH_N` remains above the inserted image, reselect exactly that placeholder text and press `Delete` first; use `Backspace` only if `Delete` fails and the Computer Use state confirms the selected text is exactly the placeholder.
- Confirm that placeholder is gone before continuing.
10. **Verify**:
- Inspect the Computer Use state for `XIMGPH_` residue.
- Confirm the expected number of image blocks is visible.
- Open Preview and verify title, cover, body, links, and images.
11. **Publish Safety**: ask the user for explicit final confirmation before clicking `Publish`.
## CDP Script Workflow (Fallback)
1. **Parse Markdown**: Extract title, cover, content images, generate HTML
2. **Launch Chrome**: Real browser with CDP, persistent login
3. **Navigate**: Open `x.com/compose/articles`
4. **Create Article**: Click create button if on list page
5. **Upload Cover**: Use file input for cover image
6. **Fill Title**: Type title into title field
7. **Paste Content**: Copy HTML to clipboard, paste into editor
8. **Insert Images**: For each placeholder in placeholder order:
- Find and click the placeholder text in the editor
- Use `Insert` -> `Media`
- Click the modal's icon button labeled `Add photos or video`
- Upload the matching image file
- Delete the leftover placeholder text with `Delete` after the image appears
9. **Post-Composition Check** (automatic):
- Scan editor for remaining `XIMGPH_` placeholders
- Compare expected vs actual image count
- Warn if issues found
10. **Review**: Browser stays open for 60s preview
11. **Publish**: Only with `--submit` flag and explicit user confirmation
## Example Session
```
User: /post-to-x article ./blog/my-post.md --cover ./thumbnail.png
Claude:
1. Detects that the user requested the Codex Chrome plugin
2. Parses markdown: title="My Post", 3 content images
3. Saves `/tmp/x-article-body.html` and `/tmp/x-article.json`
4. Uses the Chrome plugin to open X Articles and create a draft
5. Uploads thumbnail.png as cover
6. Fills title "My Post"
7. Pastes HTML content with a real Chrome paste
8. Inserts 3 images at placeholder positions
9. Opens Preview and asks before publishing
```
## Troubleshooting
- **No create button**: Ensure X Premium subscription is active
- **Cover upload fails**: Check file path and format (PNG, JPEG)
- **Images not inserting**: Verify placeholders exist in pasted content; use `Insert` -> `Media` -> modal icon button `Add photos or video`, not image clipboard paste, the dropzone text, or the hidden file input.
- **Content not pasting**: Check HTML clipboard: `${BUN_X} {baseDir}/scripts/copy-to-clipboard.ts html --file /tmp/test.html`
- **Chrome plugin `native pipe is closed`**: retry once after 2 seconds, then run Chrome skill checks; ask before opening a new Chrome window if checks pass.
- **Chrome plugin upload `Not allowed`**: enable file URL access for the Codex Chrome Extension in `chrome://extensions` → Details.
- **Computer Use unavailable**: Use the CDP fallback script, unless the user explicitly required Chrome Computer Use.
- **Placeholder remains after upload**: Select only the placeholder text and press `Delete` after upload completes. Use `Backspace` only if `Delete` fails and the selection is exactly the placeholder.
## How It Works
1. `md-to-html.ts` converts Markdown to HTML:
- Extracts frontmatter (title, cover)
- Converts markdown to HTML
- Replaces images with unique placeholders
- Downloads remote images locally
- Returns structured JSON
2. The Codex Chrome plugin publishes through the user's real Chrome session when explicitly requested:
- Uses the user's active Chrome profile and logged-in X session
- Uses the Chrome Extension browser client rather than Computer Use or CDP
- Uses `copy-to-clipboard.ts` for rich HTML body paste
- Inserts body images through X's toolbar `Insert` -> `Media` modal and its `Add photos or video` icon button
- Keeps the final publish click under user confirmation
3. Chrome Computer Use publishes through the user's visible Chrome UI:
- Uses the user's active Chrome profile and logged-in X session
- Uses `copy-to-clipboard.ts` for rich HTML body paste
- Inserts body images through X's toolbar `Insert` -> `Media` modal and its `Add photos or video` icon button
- Uses real keystrokes (`super+v`/`control+v`) through Codex Computer Use
- Keeps the final publish click under user confirmation
4. `x-article.ts` publishes via CDP as a fallback:
- Launches real Chrome (bypasses detection)
- Uses persistent profile (saved login)
- Navigates and fills editor via DOM manipulation
- Pastes HTML from system clipboard
- Finds/selects/replaces each image placeholder
references/regular-posts.md
# Regular Posts - Detailed Guide
Detailed documentation for posting text and images to X.
## Manual Workflow
If you prefer step-by-step control:
### Step 0: Prefer Chrome Computer Use in Codex
When running inside Codex, first detect whether Chrome Computer Use is enabled:
1. If Computer Use tools are already visible, call `get_app_state` for `Google Chrome`.
2. If not, use `tool_search` for `computer-use get_app_state click press_key drag scroll Google Chrome`, then call `get_app_state`.
3. If `get_app_state` succeeds, use the user's real Chrome with Computer Use for all X UI actions.
4. Use CDP scripts only when Computer Use is unavailable or explicitly requested.
If the user explicitly asks for Chrome Computer Use, do not use Playwright, the in-app Browser, or CDP without approval.
### Step 1: Copy Image to Clipboard
```bash
${BUN_X} {baseDir}/scripts/copy-to-clipboard.ts image /path/to/image.png
```
### Step 2: Paste from Clipboard
```bash
# Simple paste to frontmost app
${BUN_X} {baseDir}/scripts/paste-from-clipboard.ts
# Paste to Chrome with retries
${BUN_X} {baseDir}/scripts/paste-from-clipboard.ts --app "Google Chrome" --retries 5
# Quick paste with shorter delay
${BUN_X} {baseDir}/scripts/paste-from-clipboard.ts --delay 200
```
### Step 3: Use Chrome Computer Use (Preferred)
1. Use `get_app_state` for `Google Chrome`.
2. Navigate Chrome to `https://x.com/compose/post` if needed.
3. Click the composer and type the post text.
4. Copy each image to the clipboard with `copy-to-clipboard.ts image <path>`.
5. Press `super+v` on macOS or `control+v` on Windows/Linux with Computer Use.
6. Wait until X finishes media upload.
7. Ask for explicit confirmation before clicking `Post`.
## Image Support
- Formats: PNG, JPEG, GIF, WebP
- Max 4 images per post
- Images copied to system clipboard, then pasted via keyboard shortcut
## Example Session
```
User: /post-to-x "Hello from Claude!" --image ./screenshot.png
Claude:
1. Detects Chrome Computer Use
2. Opens X compose in the user's real Chrome
3. Types text into editor
4. Copies image to clipboard and pastes with Computer Use
5. Waits for upload and verifies the preview
6. Asks before clicking Post
```
## Troubleshooting
- **Chrome not found**: Set `X_BROWSER_CHROME_PATH` environment variable
- **Not logged in**: First run opens Chrome - log in manually, cookies are saved
- **Image paste fails**:
- Verify clipboard script: `${BUN_X} {baseDir}/scripts/copy-to-clipboard.ts image <path>`
- On macOS, grant "Accessibility" permission to Terminal/iTerm in System Settings > Privacy & Security > Accessibility
- Keep Chrome window visible and in front during paste operations
- **osascript permission denied**: Grant Terminal accessibility permissions in System Preferences
- **Rate limited**: Wait a few minutes before retrying
## How It Works
In Chrome Computer Use mode:
1. Codex controls the user's visible Google Chrome window
2. Text is typed through the real UI
3. Images are copied to the system clipboard and pasted with real keystrokes
4. The user confirms before the final public post
The `x-browser.ts` script is the CDP fallback. It uses Chrome DevTools Protocol (CDP) to:
1. Launch real Chrome (not Playwright) with `--disable-blink-features=AutomationControlled`
2. Use persistent profile directory for saved login sessions
3. Interact with X via CDP commands (Runtime.evaluate, Input.dispatchKeyEvent)
4. **Paste images using osascript** (macOS): Sends real Cmd+V keystroke to Chrome, bypassing CDP's synthetic events that X can detect
This approach bypasses X's anti-automation detection that blocks Playwright/Puppeteer.
### Image Paste Mechanism (macOS)
CDP's `Input.dispatchKeyEvent` sends "synthetic" keyboard events that websites can detect. X ignores synthetic paste events for security. The solution:
1. Copy image to system clipboard via Swift/AppKit (`copy-to-clipboard.ts`)
2. Bring Chrome to front via `osascript`
3. Send real Cmd+V keystroke via `osascript` and System Events
4. Wait for upload to complete
This requires Terminal to have "Accessibility" permission in System Settings.
scripts/bun.lock
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "baoyu-post-to-x-scripts",
"dependencies": {
"baoyu-chrome-cdp": "^0.1.1",
"baoyu-md": "^0.1.1",
"front-matter": "^4.0.2",
"highlight.js": "^11.11.1",
"marked": "^15.0.6",
"remark-cjk-friendly": "^1.1.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5",
},
},
},
"packages": {
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.1", "", {}, "sha512-OR3PQ7NzJDykCXl20TnkZuwvNQ0hsVC2czje93P72xQaA3vKOyPN/Q1CwEgKuYzP7Rka4Fdh4HvURj6AoNR7Tg=="],
"baoyu-md": ["baoyu-md@0.1.1", "", { "dependencies": { "fflate": "^0.8.2", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "juice": "^11.0.1", "marked": "^15.0.6", "reading-time": "^1.5.0", "remark-cjk-friendly": "^1.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5" } }, "sha512-yWM3SCFam9RnJZP5qnGMVAfeIfGGdJ9jjizKimbrsHubNu51JDy3XyDDJMASnOCPMck4qXfyOb08Vmxj57P0Qg=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="],
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"escape-goat": ["escape-goat@3.0.0", "", {}, "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"front-matter": ["front-matter@4.0.2", "", { "dependencies": { "js-yaml": "^3.13.1" } }, "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg=="],
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"juice": ["juice@11.1.1", "", { "dependencies": { "cheerio": "1.0.0", "commander": "^12.1.0", "entities": "^7.0.0", "mensch": "^0.3.4", "slick": "^1.12.2", "web-resource-inliner": "^8.0.0" }, "bin": { "juice": "bin/juice" } }, "sha512-4SBfZqKcc6DrIS+5b/WiGoWaZsdUPBH+e6SbRlNjJpaIRtfoBhYReAtobIEW6mcLeFFDXLBJMuZwkJLkBJjs2w=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"mensch": ["mensch@0.3.4", "", {}, "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gRzVLUdjXBLX6zNPSnHGDoo+ZTp5zy+MZm0g3sv+3chPXY7l9gW+DnrcHcZh/jiPR6MjPKO4AEJNp4Aw6V9z5Q=="],
"micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@2.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-egs6+12JU2yutskHY55FyR48ZiEcFOJFyk9rsiyIhcJ6IvWB6ABBqVrBw8IobqJTDZ/wdSr9eoXDPb5S2nW1bg=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
"reading-time": ["reading-time@1.5.0", "", {}, "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg=="],
"remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"slick": ["slick@1.12.2", "", {}, "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"valid-data-url": ["valid-data-url@3.0.1", "", {}, "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"web-resource-inliner": ["web-resource-inliner@8.0.0", "", { "dependencies": { "ansi-colors": "^4.1.1", "escape-goat": "^3.0.0", "htmlparser2": "^9.1.0", "mime": "^2.4.6", "valid-data-url": "^3.0.0" } }, "sha512-Ezr98sqXW/+OCGoUEXuOKVR+oVFlSdn1tIySEEJdiSAw4IjrW8hQkwARSSBJTSB5Us5dnytDgL0ZDliAYBhaNA=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
}
}
scripts/check-paste-permissions.ts
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { findChromeExecutable, CHROME_CANDIDATES_FULL, getDefaultProfileDir } from './x-utils.js';
interface CheckResult {
name: string;
ok: boolean;
detail: string;
}
const results: CheckResult[] = [];
function log(label: string, ok: boolean, detail: string): void {
results.push({ name: label, ok, detail });
const icon = ok ? '✅' : '❌';
console.log(`${icon} ${label}: ${detail}`);
}
function warn(label: string, detail: string): void {
results.push({ name: label, ok: true, detail });
console.log(`⚠️ ${label}: ${detail}`);
}
async function checkChrome(): Promise<void> {
const chromePath = findChromeExecutable(CHROME_CANDIDATES_FULL);
if (chromePath) {
log('Chrome', true, chromePath);
} else {
log('Chrome', false, 'Not found. Set X_BROWSER_CHROME_PATH env var or install Chrome.');
}
}
async function checkProfileIsolation(): Promise<void> {
const profileDir = getDefaultProfileDir();
const userChromeDir = process.platform === 'darwin'
? path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')
: process.platform === 'win32'
? path.join(os.homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'User Data')
: path.join(os.homedir(), '.config', 'google-chrome');
const isIsolated = !profileDir.startsWith(userChromeDir);
log('Profile isolation', isIsolated, `Skill profile: ${profileDir}`);
if (isIsolated) {
const exists = fs.existsSync(profileDir);
if (exists) {
log('Profile dir', true, 'Exists and accessible');
} else {
try {
fs.mkdirSync(profileDir, { recursive: true });
log('Profile dir', true, 'Created successfully');
} catch (e) {
log('Profile dir', false, `Cannot create: ${e instanceof Error ? e.message : String(e)}`);
}
}
}
}
async function checkAccessibility(): Promise<void> {
if (process.platform !== 'darwin') {
log('Accessibility', true, `Skipped (not macOS, platform: ${process.platform})`);
return;
}
const result = spawnSync('osascript', ['-e', `
tell application "System Events"
set frontApp to name of first application process whose frontmost is true
return frontApp
end tell
`], { stdio: 'pipe', timeout: 10_000 });
if (result.status === 0) {
const app = result.stdout?.toString().trim();
log('Accessibility (System Events)', true, `Frontmost app: ${app}`);
} else {
const stderr = result.stderr?.toString().trim() || '';
if (stderr.includes('not allowed assistive access') || stderr.includes('1002')) {
log('Accessibility (System Events)', false,
'Denied. Grant access: System Settings → Privacy & Security → Accessibility → enable your terminal app');
} else {
log('Accessibility (System Events)', false, `Failed: ${stderr}`);
}
}
}
async function checkClipboardCopy(): Promise<void> {
if (process.platform !== 'darwin') {
log('Clipboard copy (image)', true, `Skipped (not macOS)`);
return;
}
const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'x-check-'));
try {
const testPng = path.join(tmpDir, 'test.png');
const swiftSrc = `import AppKit
import Foundation
let size = NSSize(width: 2, height: 2)
let image = NSImage(size: size)
image.lockFocus()
NSColor.red.set()
NSBezierPath.fill(NSRect(origin: .zero, size: size))
image.unlockFocus()
guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: .png, properties: [:]) else {
FileHandle.standardError.write("Failed to create test PNG\\n".data(using: .utf8)!)
exit(1)
}
try png.write(to: URL(fileURLWithPath: CommandLine.arguments[1]))
`;
const genScript = path.join(tmpDir, 'gen.swift');
await writeFile(genScript, swiftSrc, 'utf8');
const genResult = spawnSync('swift', [genScript, testPng], { stdio: 'pipe', timeout: 30_000 });
if (genResult.status !== 0) {
log('Clipboard copy (image)', false, `Cannot create test image: ${genResult.stderr?.toString().trim()}`);
return;
}
const clipSrc = `import AppKit
import Foundation
guard let image = NSImage(contentsOfFile: CommandLine.arguments[1]) else {
FileHandle.standardError.write("Failed to load image\\n".data(using: .utf8)!)
exit(1)
}
let pb = NSPasteboard.general
pb.clearContents()
if !pb.writeObjects([image]) {
FileHandle.standardError.write("Failed to write to clipboard\\n".data(using: .utf8)!)
exit(1)
}
`;
const clipScript = path.join(tmpDir, 'clip.swift');
await writeFile(clipScript, clipSrc, 'utf8');
const clipResult = spawnSync('swift', [clipScript, testPng], { stdio: 'pipe', timeout: 30_000 });
if (clipResult.status === 0) {
log('Clipboard copy (image)', true, 'Can copy image to clipboard via Swift/AppKit');
} else {
log('Clipboard copy (image)', false, `Failed: ${clipResult.stderr?.toString().trim()}`);
}
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}
async function checkPasteKeystroke(): Promise<void> {
if (process.platform === 'darwin') {
const result = spawnSync('osascript', ['-e', `
tell application "System Events"
-- Dry run: just check we CAN query key sending capability
set canSend to true
return canSend
end tell
`], { stdio: 'pipe', timeout: 10_000 });
if (result.status === 0) {
log('Paste keystroke (osascript)', true, 'System Events can send keystrokes');
} else {
const stderr = result.stderr?.toString().trim() || '';
log('Paste keystroke (osascript)', false, `Cannot send keystrokes: ${stderr}`);
}
} else if (process.platform === 'linux') {
const xdotool = spawnSync('which', ['xdotool'], { stdio: 'pipe' });
const ydotool = spawnSync('which', ['ydotool'], { stdio: 'pipe' });
if (xdotool.status === 0) {
log('Paste keystroke', true, 'xdotool available (X11)');
} else if (ydotool.status === 0) {
log('Paste keystroke', true, 'ydotool available (Wayland)');
} else {
log('Paste keystroke', false, 'No tool found. Install xdotool (X11) or ydotool (Wayland).');
}
} else if (process.platform === 'win32') {
log('Paste keystroke', true, 'Windows uses PowerShell SendKeys (built-in)');
}
}
async function checkBun(): Promise<void> {
const result = spawnSync('npx', ['-y', 'bun', '--version'], { stdio: 'pipe', timeout: 30_000 });
if (result.status === 0) {
log('Bun runtime', true, `v${result.stdout?.toString().trim()}`);
} else {
log('Bun runtime', false, 'Cannot run bun. Install: brew install oven-sh/bun/bun (macOS) or npm install -g bun');
}
}
async function checkRunningChromeConflict(): Promise<void> {
if (process.platform !== 'darwin') return;
const result = spawnSync('pgrep', ['-f', 'Google Chrome'], { stdio: 'pipe' });
const pids = result.stdout?.toString().trim().split('\n').filter(Boolean) || [];
if (pids.length > 0) {
warn('Running Chrome instances', `${pids.length} Chrome process(es) detected. The skill uses --user-data-dir for isolation, so this is safe. Paste keystroke targets Chrome by app name (minor risk if multiple Chrome windows visible).`);
} else {
log('Running Chrome instances', true, 'No existing Chrome processes');
}
}
async function main(): Promise<void> {
console.log('=== baoyu-post-to-x: Permission & Environment Check ===\n');
await checkChrome();
await checkProfileIsolation();
await checkBun();
await checkAccessibility();
await checkClipboardCopy();
await checkPasteKeystroke();
await checkRunningChromeConflict();
console.log('\n--- Summary ---');
const failed = results.filter((r) => !r.ok);
if (failed.length === 0) {
console.log('All checks passed. Ready to post to X.');
} else {
console.log(`${failed.length} issue(s) found:`);
for (const f of failed) {
console.log(` ❌ ${f.name}: ${f.detail}`);
}
process.exit(1);
}
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
scripts/copy-to-clipboard.ts
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
const SUPPORTED_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
function printUsage(exitCode = 0): never {
console.log(`Copy image or HTML to system clipboard
Supports:
- Image files (jpg, png, gif, webp) - copies as image data
- HTML content - copies as rich text for paste
Usage:
# Copy image to clipboard
npx -y bun copy-to-clipboard.ts image /path/to/image.jpg
# Copy HTML to clipboard
npx -y bun copy-to-clipboard.ts html "<p>Hello</p>"
# Copy HTML from file
npx -y bun copy-to-clipboard.ts html --file /path/to/content.html
`);
process.exit(exitCode);
}
function resolvePath(filePath: string): string {
return path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
}
function inferImageMimeType(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
switch (ext) {
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.png':
return 'image/png';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
default:
return 'application/octet-stream';
}
}
type RunResult = { stdout: string; stderr: string; exitCode: number };
async function runCommand(
command: string,
args: string[],
options?: { input?: string | Buffer; allowNonZeroExit?: boolean },
): Promise<RunResult> {
return await new Promise<RunResult>((resolve, reject) => {
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on('error', reject);
child.on('close', (code) => {
resolve({
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
exitCode: code ?? 0,
});
});
if (options?.input != null) child.stdin.write(options.input);
child.stdin.end();
}).then((result) => {
if (!options?.allowNonZeroExit && result.exitCode !== 0) {
const details = result.stderr.trim() || result.stdout.trim();
throw new Error(`Command failed (${command}): exit ${result.exitCode}${details ? `\n${details}` : ''}`);
}
return result;
});
}
async function commandExists(command: string): Promise<boolean> {
if (process.platform === 'win32') {
const result = await runCommand('where', [command], { allowNonZeroExit: true });
return result.exitCode === 0 && result.stdout.trim().length > 0;
}
const result = await runCommand('which', [command], { allowNonZeroExit: true });
return result.exitCode === 0 && result.stdout.trim().length > 0;
}
async function runCommandWithFileStdin(command: string, args: string[], filePath: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const stderrChunks: Buffer[] = [];
const stdoutChunks: Buffer[] = [];
child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on('error', reject);
child.on('close', (code) => {
const exitCode = code ?? 0;
if (exitCode !== 0) {
const details = Buffer.concat(stderrChunks).toString('utf8').trim() || Buffer.concat(stdoutChunks).toString('utf8').trim();
reject(
new Error(`Command failed (${command}): exit ${exitCode}${details ? `\n${details}` : ''}`),
);
return;
}
resolve();
});
fs.createReadStream(filePath).on('error', reject).pipe(child.stdin);
});
}
async function withTempDir<T>(prefix: string, fn: (tempDir: string) => Promise<T>): Promise<T> {
const tempDir = await mkdtemp(path.join(os.tmpdir(), prefix));
try {
return await fn(tempDir);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
function getMacSwiftClipboardSource(): string {
return `import AppKit
import Foundation
func die(_ message: String, _ code: Int32 = 1) -> Never {
FileHandle.standardError.write(message.data(using: .utf8)!)
exit(code)
}
if CommandLine.arguments.count < 3 {
die("Usage: clipboard.swift <image|html> <path>\\n")
}
let mode = CommandLine.arguments[1]
let inputPath = CommandLine.arguments[2]
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
switch mode {
case "image":
guard let image = NSImage(contentsOfFile: inputPath) else {
die("Failed to load image: \\(inputPath)\\n")
}
if !pasteboard.writeObjects([image]) {
die("Failed to write image to clipboard\\n")
}
case "html":
let url = URL(fileURLWithPath: inputPath)
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
die("Failed to read HTML file: \\(inputPath)\\n")
}
_ = pasteboard.setData(data, forType: .html)
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
]
if let attr = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
pasteboard.setString(attr.string, forType: .string)
if let rtf = try? attr.data(
from: NSRange(location: 0, length: attr.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf]
) {
_ = pasteboard.setData(rtf, forType: .rtf)
}
} else if let html = String(data: data, encoding: .utf8) {
pasteboard.setString(html, forType: .string)
}
default:
die("Unknown mode: \\(mode)\\n")
}
`;
}
async function copyImageMac(imagePath: string): Promise<void> {
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const swiftPath = path.join(tempDir, 'clipboard.swift');
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
await runCommand('swift', [swiftPath, 'image', imagePath]);
});
}
async function copyHtmlMac(htmlFilePath: string): Promise<void> {
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const swiftPath = path.join(tempDir, 'clipboard.swift');
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
await runCommand('swift', [swiftPath, 'html', htmlFilePath]);
});
}
async function copyImageLinux(imagePath: string): Promise<void> {
const mime = inferImageMimeType(imagePath);
if (await commandExists('wl-copy')) {
await runCommandWithFileStdin('wl-copy', ['--type', mime], imagePath);
return;
}
if (await commandExists('xclip')) {
await runCommand('xclip', ['-selection', 'clipboard', '-t', mime, '-i', imagePath]);
return;
}
throw new Error('No clipboard tool found. Install `wl-clipboard` (wl-copy) or `xclip`.');
}
async function copyHtmlLinux(htmlFilePath: string): Promise<void> {
if (await commandExists('wl-copy')) {
await runCommandWithFileStdin('wl-copy', ['--type', 'text/html'], htmlFilePath);
return;
}
if (await commandExists('xclip')) {
await runCommand('xclip', ['-selection', 'clipboard', '-t', 'text/html', '-i', htmlFilePath]);
return;
}
throw new Error('No clipboard tool found. Install `wl-clipboard` (wl-copy) or `xclip`.');
}
async function copyImageWindows(imagePath: string): Promise<void> {
const escaped = imagePath.replace(/'/g, "''");
const ps = [
'Add-Type -AssemblyName System.Windows.Forms',
'Add-Type -AssemblyName System.Drawing',
`$img = [System.Drawing.Image]::FromFile('${escaped}')`,
'[System.Windows.Forms.Clipboard]::SetImage($img)',
'$img.Dispose()',
].join('; ');
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
}
async function copyHtmlWindows(htmlFilePath: string): Promise<void> {
const escaped = htmlFilePath.replace(/'/g, "''");
const ps = [
'Add-Type -AssemblyName System.Windows.Forms',
`$html = Get-Content -Raw -LiteralPath '${escaped}'`,
'[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)',
].join('; ');
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
}
async function copyImageToClipboard(imagePathInput: string): Promise<void> {
const imagePath = resolvePath(imagePathInput);
const ext = path.extname(imagePath).toLowerCase();
if (!SUPPORTED_IMAGE_EXTS.has(ext)) {
throw new Error(
`Unsupported image type: ${ext || '(none)'} (supported: ${Array.from(SUPPORTED_IMAGE_EXTS).join(', ')})`,
);
}
if (!fs.existsSync(imagePath)) throw new Error(`File not found: ${imagePath}`);
switch (process.platform) {
case 'darwin':
await copyImageMac(imagePath);
return;
case 'linux':
await copyImageLinux(imagePath);
return;
case 'win32':
await copyImageWindows(imagePath);
return;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
}
async function copyHtmlFileToClipboard(htmlFilePathInput: string): Promise<void> {
const htmlFilePath = resolvePath(htmlFilePathInput);
if (!fs.existsSync(htmlFilePath)) throw new Error(`File not found: ${htmlFilePath}`);
switch (process.platform) {
case 'darwin':
await copyHtmlMac(htmlFilePath);
return;
case 'linux':
await copyHtmlLinux(htmlFilePath);
return;
case 'win32':
await copyHtmlWindows(htmlFilePath);
return;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
}
async function readStdinText(): Promise<string | null> {
if (process.stdin.isTTY) return null;
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const text = Buffer.concat(chunks).toString('utf8');
return text.length > 0 ? text : null;
}
async function copyHtmlToClipboard(args: string[]): Promise<void> {
let htmlFile: string | undefined;
const positional: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i] ?? '';
if (arg === '--help' || arg === '-h') printUsage(0);
if (arg === '--file') {
htmlFile = args[i + 1];
i += 1;
continue;
}
if (arg.startsWith('--file=')) {
htmlFile = arg.slice('--file='.length);
continue;
}
if (arg === '--') {
positional.push(...args.slice(i + 1));
break;
}
if (arg.startsWith('-')) {
throw new Error(`Unknown option: ${arg}`);
}
positional.push(arg);
}
if (htmlFile && positional.length > 0) {
throw new Error('Do not pass HTML text when using --file.');
}
if (htmlFile) {
await copyHtmlFileToClipboard(htmlFile);
return;
}
const htmlFromArgs = positional.join(' ').trim();
const htmlFromStdin = (await readStdinText())?.trim() ?? '';
const html = htmlFromArgs || htmlFromStdin;
if (!html) throw new Error('Missing HTML input. Provide a string or use --file.');
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const htmlPath = path.join(tempDir, 'input.html');
await writeFile(htmlPath, html, 'utf8');
await copyHtmlFileToClipboard(htmlPath);
});
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
if (argv.length === 0) printUsage(1);
const command = argv[0];
if (command === '--help' || command === '-h') printUsage(0);
if (command === 'image') {
const imagePath = argv[1];
if (!imagePath) throw new Error('Missing image path.');
await copyImageToClipboard(imagePath);
return;
}
if (command === 'html') {
await copyHtmlToClipboard(argv.slice(1));
return;
}
throw new Error(`Unknown command: ${command}`);
}
await main().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
});
scripts/md-to-html.test.ts
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { parseMarkdown } from './md-to-html.ts';
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test('parseMarkdown preserves mixed markdown and Obsidian wikilink image order', async (t) => {
const root = await makeTempDir('x-md-to-html-wikilinks-');
t.after(() => fs.rm(root, { recursive: true, force: true }));
const articleDir = path.join(root, 'article');
const attachmentsDir = path.join(articleDir, 'Attachments');
const tempDir = path.join(root, 'tmp');
await fs.mkdir(attachmentsDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(articleDir, 'a.png'), 'a');
await fs.writeFile(path.join(articleDir, 'b.jpg'), 'b');
await fs.writeFile(path.join(attachmentsDir, 'c.webp'), 'c');
const markdownPath = path.join(articleDir, 'post.md');
await fs.writeFile(
markdownPath,
[
'# Title',
'',
'![[a.png]]',
'',
'',
'',
'![[c.webp|C alt]]',
'',
'![[note]]',
].join('\n'),
);
const result = await parseMarkdown(markdownPath, { tempDir });
assert.deepEqual(
result.contentImages.map(({ placeholder, originalPath, alt, localPath }) => ({
placeholder,
originalPath,
alt,
localPath,
})),
[
{
placeholder: 'XIMGPH_1',
originalPath: 'a.png',
alt: '',
localPath: path.join(articleDir, 'a.png'),
},
{
placeholder: 'XIMGPH_2',
originalPath: 'b.jpg',
alt: 'B alt',
localPath: path.join(articleDir, 'b.jpg'),
},
{
placeholder: 'XIMGPH_3',
originalPath: 'c.webp',
alt: 'C alt',
localPath: path.join(attachmentsDir, 'c.webp'),
},
],
);
assert.match(result.html, /XIMGPH_1[\s\S]*XIMGPH_2[\s\S]*XIMGPH_3/);
assert.match(result.html, /!\[\[note\]\]/);
});
test('parseMarkdown resolves encoded spaces and literal percent image paths', async (t) => {
const root = await makeTempDir('baoyu-post-to-x-images-');
t.after(() => fs.rm(root, { recursive: true, force: true }));
const articlePath = path.join(root, 'article.md');
const tempDir = path.join(root, 'tmp');
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(root, 'Pasted image.png'), 'png');
await fs.writeFile(path.join(root, '100%.png'), 'png');
await fs.writeFile(
articlePath,
[
'# Title',
'',
'',
'',
'',
].join('\n'),
);
const result = await parseMarkdown(articlePath, { tempDir });
assert.equal(result.contentImages[0]?.localPath, path.join(root, 'Pasted image.png'));
assert.equal(result.contentImages[1]?.localPath, path.join(root, '100%.png'));
});
test('parseMarkdown renders CJK-adjacent bold and italics (no literal asterisks)', async (t) => {
const root = await makeTempDir('x-md-to-html-cjk-bold-');
t.after(() => fs.rm(root, { recursive: true, force: true }));
const markdownPath = path.join(root, 'post.md');
const tempDir = path.join(root, 'tmp');
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(
markdownPath,
[
'# 标题',
'',
'分工在变细。**国际大厂卷基础设施,中文项目卷场景落地。**这其实是生态成熟的表现。',
'',
'半角场景 **Top 10 里平均有 8 个** 项目。',
'',
'斜体 *数据来源 GitHub* 收尾。',
'',
'参考 **[docs][d]** 了解更多。',
'',
'[d]: https://example.com',
].join('\n'),
);
const result = await parseMarkdown(markdownPath, { tempDir });
// Bold directly adjacent to CJK (closing ** followed by CJK) must render.
assert.match(result.html, /<strong>国际大厂卷基础设施,中文项目卷场景落地。<\/strong>/);
assert.match(result.html, /<strong>Top 10 里平均有 8 个<\/strong>/);
// Italics.
assert.match(result.html, /<em>数据来源 GitHub<\/em>/);
// Reference-style links inside emphasis must render as links, not plain text.
assert.match(result.html, /<strong><a href="https:\/\/example\.com" rel="noopener noreferrer nofollow">docs<\/a><\/strong>/);
// No literal emphasis delimiters should leak into the output.
assert.doesNotMatch(result.html, /\*\*/);
assert.doesNotMatch(result.html, /(?<!\*)\*(?!\*)[^*\n]+\*(?!\*)/);
});
test('parseMarkdown does not decode author-written literal HTML entities into tags', async (t) => {
const root = await makeTempDir('x-md-to-html-entities-');
t.after(() => fs.rm(root, { recursive: true, force: true }));
const markdownPath = path.join(root, 'post.md');
const tempDir = path.join(root, 'tmp');
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(
markdownPath,
[
'# 标题',
'',
'正文中写 <b>literal</b> 想显示字面标签。**加粗**收尾。',
'',
'代码里写 `<b>` 同样保留。',
].join('\n'),
);
const result = await parseMarkdown(markdownPath, { tempDir });
// CJK-adjacent bold still renders.
assert.match(result.html, /<strong>加粗<\/strong>/);
// Author-written literal entities must NOT be decoded into real tags.
assert.doesNotMatch(result.html, /<b>literal<\/b>/);
assert.match(result.html, /<b>literal<\/b>/);
});
scripts/md-to-html.ts
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
import frontMatter from 'front-matter';
import hljs from 'highlight.js/lib/common';
import { Lexer, Marked, type RendererObject, type Tokens } from 'marked';
import { unified } from 'unified';
import remarkCjkFriendly from 'remark-cjk-friendly';
import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify';
import {
preprocessMermaidInMarkdown,
replaceMarkdownImagesWithPlaceholders,
resolveImagePath,
} from 'baoyu-md';
import { closeRenderer, renderMermaidToPng } from 'baoyu-chrome-cdp/mermaid';
interface ImageInfo {
placeholder: string;
localPath: string;
originalPath: string;
blockIndex: number;
alt?: string;
}
interface ParsedMarkdown {
title: string;
coverImage: string | null;
contentImages: ImageInfo[];
html: string;
totalBlocks: number;
}
type FrontmatterFields = Record<string, unknown>;
function parseFrontmatter(content: string): { frontmatter: FrontmatterFields; body: string } {
try {
const parsed = frontMatter<FrontmatterFields>(content);
return {
frontmatter: parsed.attributes ?? {},
body: parsed.body,
};
} catch {
return { frontmatter: {}, body: content };
}
}
function stripWrappingQuotes(value: string): string {
if (!value) return value;
const doubleQuoted = value.startsWith('"') && value.endsWith('"');
const singleQuoted = value.startsWith("'") && value.endsWith("'");
const cjkDoubleQuoted = value.startsWith('\u201c') && value.endsWith('\u201d');
const cjkSingleQuoted = value.startsWith('\u2018') && value.endsWith('\u2019');
if (doubleQuoted || singleQuoted || cjkDoubleQuoted || cjkSingleQuoted) {
return value.slice(1, -1).trim();
}
return value.trim();
}
function toFrontmatterString(value: unknown): string | undefined {
if (typeof value === 'string') {
return stripWrappingQuotes(value);
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return undefined;
}
function pickFirstString(frontmatter: FrontmatterFields, keys: string[]): string | undefined {
for (const key of keys) {
const value = toFrontmatterString(frontmatter[key]);
if (value) return value;
}
return undefined;
}
function findCoverImageNearMarkdown(baseDir: string): string | null {
const candidateDirs = [baseDir, path.join(baseDir, 'imgs')];
const coverPattern = /^cover\.(png|jpe?g|webp)$/i;
for (const dir of candidateDirs) {
try {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
continue;
}
const match = fs.readdirSync(dir).find((entry) => coverPattern.test(entry));
if (match) {
return path.join(dir, match);
}
} catch {
continue;
}
}
return null;
}
function extractTitleFromMarkdown(markdown: string): string {
const tokens = Lexer.lex(markdown, { gfm: true, breaks: true });
for (const token of tokens) {
if (token.type === 'heading' && token.depth === 1) {
return stripWrappingQuotes(token.text);
}
}
return '';
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function highlightCode(code: string, lang: string): string {
try {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
}
return hljs.highlightAuto(code).value;
} catch {
return escapeHtml(code);
}
}
// Normalize CJK-adjacent emphasis so `marked` renders it correctly.
//
// `marked`'s emphasis tokenizer treats a closing `**`/`*` directly followed by a
// CJK character as not right-flanking, so it leaves the delimiters literal
// (e.g. `**加粗**这` renders as plain text with the asterisks intact). We round-trip
// the markdown through `remark-cjk-friendly`, whose stringify serializes the
// boundary character as an HTML entity (`这`); the entity is treated as
// punctuation by `marked`'s flanking rules, so emphasis parses as expected.
//
// We deliberately do NOT decode the entities afterward. They are valid HTML
// character references that render correctly when the article HTML is pasted into
// the X editor, and `marked` only emits them for characters outside the emphasis
// span (the boundary char), never inside it. A blanket decode of the rendered
// HTML would risk turning author-written literal entities (e.g. `<b>`
// meant to display `<b>` as text) into real tags, so we leave them intact.
function preprocessCjkMarkdown(markdown: string): string {
try {
const processor = unified()
.use(remarkParse)
.use(remarkCjkFriendly)
.use(remarkStringify);
return String(processor.processSync(markdown));
} catch {
return markdown;
}
}
function convertMarkdownToHtml(markdown: string): { html: string; totalBlocks: number } {
const preprocessedMarkdown = preprocessCjkMarkdown(markdown);
const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true });
const renderer: RendererObject = {
heading({ depth, tokens }: Tokens.Heading): string {
if (depth === 1) {
return '';
}
return `<h2>${this.parser.parseInline(tokens)}</h2>`;
},
paragraph({ tokens }: Tokens.Paragraph): string {
const text = this.parser.parseInline(tokens).trim();
if (!text) return '';
return `<p>${text}</p>`;
},
blockquote({ tokens }: Tokens.Blockquote): string {
return `<blockquote>${this.parser.parse(tokens)}</blockquote>`;
},
code({ text, lang = '' }: Tokens.Code): string {
const language = lang.split(/\s+/)[0]!.toLowerCase();
const source = text.replace(/\n$/, '');
const highlighted = highlightCode(source, language).replace(/\n/g, '<br>');
const label = language ? `<strong>[${escapeHtml(language)}]</strong><br>` : '';
return `<blockquote>${label}${highlighted}</blockquote>`;
},
image({ href, text }: Tokens.Image): string {
if (!href) return '';
return escapeHtml(text ?? '');
},
link({ href, title, tokens, text }: Tokens.Link): string {
const label = tokens?.length ? this.parser.parseInline(tokens) : escapeHtml(text || href || '');
if (!href) return label;
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
return `<a href="${escapeHtml(href)}"${titleAttr} rel="noopener noreferrer nofollow">${label}</a>`;
},
};
const parser = new Marked({
gfm: true,
breaks: true,
});
parser.use({ renderer });
const rendered = parser.parse(preprocessedMarkdown);
if (typeof rendered !== 'string') {
throw new Error('Unexpected async markdown parse result');
}
const totalBlocks = blockTokens.filter((token) => {
if (token.type === 'space') return false;
if (token.type === 'heading' && token.depth === 1) return false;
return true;
}).length;
return {
html: rendered,
totalBlocks,
};
}
export async function parseMarkdown(
markdownPath: string,
options?: { coverImage?: string; title?: string; tempDir?: string },
): Promise<ParsedMarkdown> {
const content = fs.readFileSync(markdownPath, 'utf-8');
const baseDir = path.dirname(markdownPath);
const tempDir = options?.tempDir ?? path.join(os.tmpdir(), 'x-article-images');
await mkdir(tempDir, { recursive: true });
const { frontmatter, body } = parseFrontmatter(content);
let title = stripWrappingQuotes(options?.title ?? '') || pickFirstString(frontmatter, ['title']) || '';
if (!title) {
title = extractTitleFromMarkdown(body);
}
if (!title) {
title = path.basename(markdownPath, path.extname(markdownPath));
}
let coverImagePath = stripWrappingQuotes(options?.coverImage ?? '') || pickFirstString(frontmatter, [
'cover_image',
'coverImage',
'cover',
'image',
'featureImage',
'feature_image',
]) || null;
if (!coverImagePath) {
coverImagePath = findCoverImageNearMarkdown(baseDir);
}
const { markdown: mermaidProcessedBody, images: mermaidImages } =
await preprocessMermaidInMarkdown(body, {
baseDir,
renderFn: renderMermaidToPng,
onError: (error, block) => {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[md-to-html] mermaid render failed (${block.code.slice(0, 40).replace(/\s+/g, ' ')}…): ${message}`,
);
},
});
if (mermaidImages.length > 0) {
const fresh = mermaidImages.filter((image) => !image.cached).length;
console.error(
`[md-to-html] mermaid: ${mermaidImages.length} block(s), ${fresh} rendered, ${mermaidImages.length - fresh} cached`,
);
}
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
mermaidProcessedBody,
'XIMGPH_',
);
const { html, totalBlocks } = convertMarkdownToHtml(rewrittenBody);
const htmlLines = html.split('\n');
const imageBlockIndexes = new Map<string, number>();
for (let i = 0; i < images.length; i++) {
const placeholder = images[i]!.placeholder;
for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {
const regex = new RegExp(`\\b${escapeRegExp(placeholder)}\\b`);
if (regex.test(htmlLines[lineIndex]!)) {
imageBlockIndexes.set(placeholder, lineIndex);
break;
}
}
}
const contentImages: ImageInfo[] = [];
let firstImageAsCover: string | null = null;
for (let i = 0; i < images.length; i++) {
const img = images[i]!;
const localPath = await resolveImagePath(img.originalPath, baseDir, tempDir, 'md-to-html');
if (i === 0 && !coverImagePath) {
firstImageAsCover = localPath;
}
contentImages.push({
placeholder: img.placeholder,
localPath,
originalPath: img.originalPath,
alt: img.alt,
blockIndex: imageBlockIndexes.get(img.placeholder) ?? -1,
});
}
const finalHtml = html.replace(/\n{3,}/g, '\n\n').trim();
let resolvedCoverImage: string | null = null;
if (coverImagePath) {
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir, 'md-to-html');
} else if (firstImageAsCover) {
resolvedCoverImage = firstImageAsCover;
}
return {
title,
coverImage: resolvedCoverImage,
contentImages,
html: finalHtml,
totalBlocks,
};
}
function printUsage(): never {
console.log(`Convert Markdown to HTML for X Article publishing
Usage:
npx -y bun md-to-html.ts <markdown_file> [options]
Options:
--title <title> Override title from frontmatter
--cover <image> Override cover image from frontmatter
--output <json|html> Output format (default: json)
--html-only Output only the HTML content
--save-html <path> Save HTML to file
Frontmatter fields:
title: Article title (or use first H1)
cover_image: Cover image path or URL
cover: Alias for cover_image
image: Alias for cover_image
Example:
npx -y bun md-to-html.ts article.md --output json
npx -y bun md-to-html.ts article.md --html-only > /tmp/article.html
npx -y bun md-to-html.ts article.md --save-html /tmp/article.html
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printUsage();
}
let markdownPath: string | undefined;
let title: string | undefined;
let coverImage: string | undefined;
let outputFormat: 'json' | 'html' = 'json';
let htmlOnly = false;
let saveHtmlPath: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--title' && args[i + 1]) {
title = args[++i];
} else if (arg === '--cover' && args[i + 1]) {
coverImage = args[++i];
} else if (arg === '--output' && args[i + 1]) {
outputFormat = args[++i] as 'json' | 'html';
} else if (arg === '--html-only') {
htmlOnly = true;
} else if (arg === '--save-html' && args[i + 1]) {
saveHtmlPath = args[++i];
} else if (!arg.startsWith('-')) {
markdownPath = arg;
}
}
if (!markdownPath) {
console.error('Error: Markdown file path required');
process.exit(1);
}
if (!fs.existsSync(markdownPath)) {
console.error(`Error: File not found: ${markdownPath}`);
process.exit(1);
}
const result = await parseMarkdown(markdownPath, { title, coverImage });
if (saveHtmlPath) {
await writeFile(saveHtmlPath, result.html, 'utf-8');
console.error(`[md-to-html] HTML saved to: ${saveHtmlPath}`);
}
if (htmlOnly) {
console.log(result.html);
} else if (outputFormat === 'html') {
console.log(result.html);
} else {
console.log(JSON.stringify(result, null, 2));
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
await main();
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
} finally {
await closeRenderer();
}
}
scripts/package.json
{
"name": "baoyu-post-to-x-scripts",
"private": true,
"type": "module",
"dependencies": {
"baoyu-chrome-cdp": "^0.1.1",
"baoyu-md": "^0.1.1",
"front-matter": "^4.0.2",
"highlight.js": "^11.11.1",
"marked": "^15.0.6",
"remark-cjk-friendly": "^1.1.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5"
}
}
scripts/paste-from-clipboard.ts
import { spawnSync } from 'node:child_process';
import process from 'node:process';
function printUsage(exitCode = 0): never {
console.log(`Send real paste keystroke (Cmd+V / Ctrl+V) to the frontmost application
This bypasses CDP's synthetic events which websites can detect and ignore.
Usage:
npx -y bun paste-from-clipboard.ts [options]
Options:
--retries <n> Number of retry attempts (default: 3)
--delay <ms> Delay between retries in ms (default: 500)
--app <name> Target application to activate first (macOS only)
--help Show this help
Examples:
# Simple paste
npx -y bun paste-from-clipboard.ts
# Paste to Chrome with retries
npx -y bun paste-from-clipboard.ts --app "Google Chrome" --retries 5
# Quick paste with shorter delay
npx -y bun paste-from-clipboard.ts --delay 200
`);
process.exit(exitCode);
}
function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function activateApp(appName: string): boolean {
if (process.platform !== 'darwin') return false;
// Activate and wait for app to be frontmost
const script = `
tell application "${appName}"
activate
delay 0.5
end tell
-- Verify app is frontmost
tell application "System Events"
set frontApp to name of first application process whose frontmost is true
if frontApp is not "${appName}" then
tell application "${appName}" to activate
delay 0.3
end if
end tell
`;
const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' });
return result.status === 0;
}
function pasteMac(retries: number, delayMs: number, targetApp?: string): boolean {
for (let i = 0; i < retries; i++) {
// Build script that activates app (if specified) and sends keystroke in one atomic operation
const script = targetApp
? `
tell application "${targetApp}"
activate
end tell
delay 0.3
tell application "System Events"
keystroke "v" using command down
end tell
`
: `
tell application "System Events"
keystroke "v" using command down
end tell
`;
const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
const stderr = result.stderr?.toString().trim();
if (stderr) {
console.error(`[paste] osascript error: ${stderr}`);
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
function pasteLinux(retries: number, delayMs: number): boolean {
// Try xdotool first (X11), then ydotool (Wayland)
const tools = [
{ cmd: 'xdotool', args: ['key', 'ctrl+v'] },
{ cmd: 'ydotool', args: ['key', '29:1', '47:1', '47:0', '29:0'] }, // Ctrl down, V down, V up, Ctrl up
];
for (const tool of tools) {
const which = spawnSync('which', [tool.cmd], { stdio: 'pipe' });
if (which.status !== 0) continue;
for (let i = 0; i < retries; i++) {
const result = spawnSync(tool.cmd, tool.args, { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
console.error('[paste] No supported tool found. Install xdotool (X11) or ydotool (Wayland).');
return false;
}
function pasteWindows(retries: number, delayMs: number): boolean {
const ps = `
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.SendKeys]::SendWait("^v")
`;
for (let i = 0; i < retries; i++) {
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
function paste(retries: number, delayMs: number, targetApp?: string): boolean {
switch (process.platform) {
case 'darwin':
return pasteMac(retries, delayMs, targetApp);
case 'linux':
return pasteLinux(retries, delayMs);
case 'win32':
return pasteWindows(retries, delayMs);
default:
console.error(`[paste] Unsupported platform: ${process.platform}`);
return false;
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
let retries = 3;
let delayMs = 500;
let targetApp: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i] ?? '';
if (arg === '--help' || arg === '-h') {
printUsage(0);
}
if (arg === '--retries' && args[i + 1]) {
retries = parseInt(args[++i]!, 10) || 3;
} else if (arg === '--delay' && args[i + 1]) {
delayMs = parseInt(args[++i]!, 10) || 500;
} else if (arg === '--app' && args[i + 1]) {
targetApp = args[++i];
} else if (arg.startsWith('-')) {
console.error(`Unknown option: ${arg}`);
printUsage(1);
}
}
if (targetApp) {
console.log(`[paste] Target app: ${targetApp}`);
}
console.log(`[paste] Sending paste keystroke (retries=${retries}, delay=${delayMs}ms)...`);
const success = paste(retries, delayMs, targetApp);
if (success) {
console.log('[paste] Paste keystroke sent successfully');
} else {
console.error('[paste] Failed to send paste keystroke');
process.exit(1);
}
}
await main();
scripts/x-article.ts
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { parseMarkdown } from './md-to-html.js';
import {
CHROME_CANDIDATES_BASIC,
CdpConnection,
copyHtmlToClipboard,
copyImageToClipboard,
findExistingChromeDebugPort,
getDefaultProfileDir,
launchChrome,
openPageSession,
pasteFromClipboard,
sleep,
waitForChromeDebugPort,
} from './x-utils.js';
const X_ARTICLES_URL = 'https://x.com/compose/articles';
const I18N_SELECTORS = {
titleInput: [
'textarea[placeholder="Add a title"]',
'textarea[placeholder="添加标题"]',
'textarea[placeholder="タイトルを追加"]',
'textarea[placeholder="제목 추가"]',
'textarea[name="Article Title"]',
],
addPhotosButton: [
'[aria-label="Add photos or video"]',
'[aria-label="添加照片或视频"]',
'[aria-label="写真や動画を追加"]',
'[aria-label="사진 또는 동영상 추가"]',
],
previewButton: [
'a[href*="/preview"]',
'[data-testid="previewButton"]',
'button[aria-label*="preview" i]',
'button[aria-label*="预览" i]',
'button[aria-label*="プレビュー" i]',
'button[aria-label*="미리보기" i]',
],
publishButton: [
'[data-testid="publishButton"]',
'button[aria-label*="publish" i]',
'button[aria-label*="发布" i]',
'button[aria-label*="公開" i]',
'button[aria-label*="게시" i]',
],
};
interface ArticleOptions {
markdownPath: string;
coverImage?: string;
title?: string;
submit?: boolean;
profileDir?: string;
chromePath?: string;
}
export async function publishArticle(options: ArticleOptions): Promise<void> {
const { markdownPath, submit = false, profileDir = getDefaultProfileDir() } = options;
console.log('[x-article] Parsing markdown...');
const parsed = await parseMarkdown(markdownPath, {
title: options.title,
coverImage: options.coverImage,
});
console.log(`[x-article] Title: ${parsed.title}`);
console.log(`[x-article] Cover: ${parsed.coverImage ?? 'none'}`);
console.log(`[x-article] Content images: ${parsed.contentImages.length}`);
// Save HTML to temp file
const htmlPath = path.join(os.tmpdir(), 'x-article-content.html');
await writeFile(htmlPath, parsed.html, 'utf-8');
console.log(`[x-article] HTML saved to: ${htmlPath}`);
await mkdir(profileDir, { recursive: true });
const existingPort = await findExistingChromeDebugPort(profileDir);
const reusing = existingPort !== null;
let port = existingPort ?? 0;
if (reusing) {
console.log(`[x-article] Reusing existing Chrome instance on port ${port}`);
} else {
console.log(`[x-article] Launching Chrome...`);
const launched = await launchChrome(X_ARTICLES_URL, profileDir, CHROME_CANDIDATES_BASIC, options.chromePath);
port = launched.port;
}
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 60_000 });
const page = await openPageSession({
cdp,
reusing,
url: X_ARTICLES_URL,
matchTarget: (target) => target.type === 'page' && target.url.startsWith(X_ARTICLES_URL),
enablePage: true,
enableRuntime: true,
enableDom: true,
});
const { sessionId } = page;
console.log('[x-article] Waiting for articles page...');
await sleep(1000);
// Wait for and click "create" button
const waitForElement = async (selector: string, timeoutMs = 60_000): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('${selector}')`,
returnByValue: true,
}, { sessionId });
if (result.result.value) return true;
await sleep(500);
}
return false;
};
const clickElement = async (selector: string): Promise<boolean> => {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => { const el = document.querySelector('${selector}'); if (el) { el.click(); return true; } return false; })()`,
returnByValue: true,
}, { sessionId });
return result.result.value;
};
const typeText = async (selector: string, text: string): Promise<void> => {
await cdp!.send('Runtime.evaluate', {
expression: `(() => {
const el = document.querySelector('${selector}');
if (el) {
el.focus();
document.execCommand('insertText', false, ${JSON.stringify(text)});
}
})()`,
}, { sessionId });
};
const pressKey = async (key: string, modifiers = 0): Promise<void> => {
await cdp!.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key,
code: `Key${key.toUpperCase()}`,
modifiers,
windowsVirtualKeyCode: key.toUpperCase().charCodeAt(0),
}, { sessionId });
await cdp!.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key,
code: `Key${key.toUpperCase()}`,
modifiers,
windowsVirtualKeyCode: key.toUpperCase().charCodeAt(0),
}, { sessionId });
};
// Check if we're on the articles list page (has Write button)
console.log('[x-article] Looking for Write button...');
const writeButtonFound = await waitForElement('[data-testid="empty_state_button_text"]', 30_000);
if (writeButtonFound) {
console.log('[x-article] Clicking Write button...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="empty_state_button_text"]')?.click()`,
}, { sessionId });
await sleep(2000);
} else {
console.log('[x-article] Write button not found, looking for create button...');
const createClicked = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const selectors = [
'button[aria-label="create"]',
'button[aria-label="Create"]',
'button[aria-label*="create" i]',
'button[aria-label*="write" i]',
'a[href="/compose/articles"]'
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el instanceof HTMLElement) {
el.click();
return true;
}
}
const buttons = [...document.querySelectorAll('button')];
const byText = buttons.find((el) => /^(create|write)$/i.test((el.textContent || '').trim()));
if (byText instanceof HTMLElement) {
byText.click();
return true;
}
return false;
})()`,
returnByValue: true,
}, { sessionId });
if (createClicked.result.value) {
console.log('[x-article] Create button clicked');
await sleep(2000);
} else {
console.log('[x-article] Create button not found');
}
}
// Wait for editor (title textarea)
const titleSelectors = I18N_SELECTORS.titleInput.join(', ');
console.log('[x-article] Waiting for editor...');
const editorFound = await waitForElement(titleSelectors, 30_000);
if (!editorFound) {
console.log('[x-article] Editor not found. Please ensure you have X Premium and are logged in.');
await sleep(60_000);
throw new Error('Editor not found');
}
// Upload cover image
if (parsed.coverImage) {
console.log('[x-article] Uploading cover image...');
// Click "Add photos or video" button
const addPhotosSelectors = JSON.stringify(I18N_SELECTORS.addPhotosButton);
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const selectors = ${addPhotosSelectors};
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.click(); return true; }
}
return false;
})()`,
}, { sessionId });
await sleep(500);
// Use file input directly
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId });
const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
nodeId: root.nodeId,
selector: '[data-testid="fileInput"], input[type="file"][accept*="image"]',
}, { sessionId });
if (nodeId) {
await cdp.send('DOM.setFileInputFiles', {
nodeId,
files: [parsed.coverImage],
}, { sessionId });
console.log('[x-article] Cover image file set');
// Wait for Apply button to appear and click it
console.log('[x-article] Waiting for Apply button...');
const applyFound = await waitForElement('[data-testid="applyButton"]', 15_000);
if (applyFound) {
// Check if modal is present
const isModalOpen = async (): Promise<boolean> => {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[role="dialog"][aria-modal="true"]')`,
returnByValue: true,
}, { sessionId });
return result.result.value;
};
// Click Apply button with retry logic
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
console.log(`[x-article] Clicking Apply button (attempt ${attempt}/${maxRetries})...`);
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="applyButton"]')?.click()`,
}, { sessionId });
// Wait for modal to close (up to 5 seconds per attempt)
const closeTimeout = 5000;
const checkInterval = 300;
const startTime = Date.now();
let modalClosed = false;
while (Date.now() - startTime < closeTimeout) {
await sleep(checkInterval);
const stillOpen = await isModalOpen();
if (!stillOpen) {
modalClosed = true;
break;
}
}
if (modalClosed) {
console.log('[x-article] Cover image applied, modal closed');
await sleep(500);
break;
}
if (attempt < maxRetries) {
console.log('[x-article] Modal still open, retrying...');
} else {
console.log('[x-article] Modal did not close after all attempts, continuing anyway...');
}
}
} else {
console.log('[x-article] Apply button not found, continuing...');
}
}
}
// Fill title using keyboard input
if (parsed.title) {
console.log('[x-article] Filling title...');
// Focus title input
const titleInputSelectors = JSON.stringify(I18N_SELECTORS.titleInput);
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const selectors = ${titleInputSelectors};
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.focus(); return true; }
}
return false;
})()`,
}, { sessionId });
await sleep(200);
// Type title character by character using insertText
await cdp.send('Input.insertText', { text: parsed.title }, { sessionId });
await sleep(300);
// Tab out to trigger save
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }, { sessionId });
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }, { sessionId });
await sleep(500);
}
// Insert HTML content
console.log('[x-article] Inserting content...');
// Read HTML content
const htmlContent = fs.readFileSync(htmlPath, 'utf-8');
// Focus on DraftEditor body
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (editor) {
editor.focus();
editor.click();
return true;
}
return false;
})()`,
}, { sessionId });
await sleep(300);
// Method 1: Simulate paste event with HTML data
console.log('[x-article] Attempting to insert HTML via paste event...');
const pasteResult = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (!editor) return false;
const html = ${JSON.stringify(htmlContent)};
// Create a paste event with HTML data
const dt = new DataTransfer();
dt.setData('text/html', html);
dt.setData('text/plain', html.replace(/<[^>]*>/g, ''));
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dt
});
editor.dispatchEvent(pasteEvent);
return true;
})()`,
returnByValue: true,
}, { sessionId });
await sleep(1000);
// Check if content was inserted
const contentCheck = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText?.length || 0`,
returnByValue: true,
}, { sessionId });
if (contentCheck.result.value > 50) {
console.log(`[x-article] Content inserted successfully (${contentCheck.result.value} chars)`);
} else {
console.log('[x-article] Paste event may not have worked, trying insertHTML...');
// Method 2: Use execCommand insertHTML
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (!editor) return false;
editor.focus();
document.execCommand('insertHTML', false, ${JSON.stringify(htmlContent)});
return true;
})()`,
}, { sessionId });
await sleep(1000);
// Check again
const check2 = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText?.length || 0`,
returnByValue: true,
}, { sessionId });
if (check2.result.value > 50) {
console.log(`[x-article] Content inserted via execCommand (${check2.result.value} chars)`);
} else {
console.log('[x-article] Auto-insert failed. HTML copied to clipboard - please paste manually (Cmd+V)');
copyHtmlToClipboard(htmlPath);
// Wait for manual paste
console.log('[x-article] Waiting 30s for manual paste...');
await sleep(30_000);
}
}
// Insert content images (reverse order to maintain positions)
if (parsed.contentImages.length > 0) {
console.log('[x-article] Inserting content images...');
// First, check what placeholders exist in the editor
const editorContent = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText || ''`,
returnByValue: true,
}, { sessionId });
console.log('[x-article] Checking for placeholders in content...');
for (const img of parsed.contentImages) {
// Use regex for exact match (not followed by digit, e.g., XIMGPH_1 should not match XIMGPH_10)
const regex = new RegExp(img.placeholder + '(?!\\d)');
if (regex.test(editorContent.result.value)) {
console.log(`[x-article] Found: ${img.placeholder}`);
} else {
console.log(`[x-article] NOT found: ${img.placeholder}`);
}
}
// Process images in XIMGPH order (1, 2, 3, ...) regardless of blockIndex
const getPlaceholderIndex = (placeholder: string): number => {
const match = placeholder.match(/XIMGPH_(\d+)/);
return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
};
const sortedImages = [...parsed.contentImages].sort(
(a, b) => getPlaceholderIndex(a.placeholder) - getPlaceholderIndex(b.placeholder),
);
for (let i = 0; i < sortedImages.length; i++) {
const img = sortedImages[i]!;
console.log(`[x-article] [${i + 1}/${sortedImages.length}] Inserting image at placeholder: ${img.placeholder}`);
// Helper to select placeholder with retry
const selectPlaceholder = async (maxRetries = 3): Promise<boolean> => {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
// Find, scroll to, and select the placeholder text in DraftEditor
await cdp!.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');
if (!editor) return false;
const placeholder = ${JSON.stringify(img.placeholder)};
// Search through all text nodes in the editor
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);
let node;
while ((node = walker.nextNode())) {
const text = node.textContent || '';
let searchStart = 0;
let idx;
// Search for exact match (not prefix of longer placeholder like XIMGPH_1 in XIMGPH_10)
while ((idx = text.indexOf(placeholder, searchStart)) !== -1) {
const afterIdx = idx + placeholder.length;
const charAfter = text[afterIdx];
// Exact match if next char is not a digit (XIMGPH_1 should not match XIMGPH_10)
if (charAfter === undefined || !/\\d/.test(charAfter)) {
// Found exact placeholder - scroll to it first
const parentElement = node.parentElement;
if (parentElement) {
parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Select it
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + placeholder.length);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
return true;
}
searchStart = afterIdx;
}
}
return false;
})()`,
}, { sessionId });
// Wait for scroll and selection to settle
await sleep(800);
// Verify selection matches the placeholder
const selectionCheck = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `window.getSelection()?.toString() || ''`,
returnByValue: true,
}, { sessionId });
const selectedText = selectionCheck.result.value.trim();
if (selectedText === img.placeholder) {
console.log(`[x-article] Selection verified: "${selectedText}"`);
return true;
}
if (attempt < maxRetries) {
console.log(`[x-article] Selection attempt ${attempt} got "${selectedText}", retrying...`);
await sleep(500);
} else {
console.warn(`[x-article] Selection failed after ${maxRetries} attempts, got: "${selectedText}"`);
}
}
return false;
};
// Try to select the placeholder
const selected = await selectPlaceholder(3);
if (!selected) {
console.warn(`[x-article] Skipping image - could not select placeholder: ${img.placeholder}`);
continue;
}
console.log(`[x-article] Copying image: ${path.basename(img.localPath)}`);
// Copy image to clipboard
if (!copyImageToClipboard(img.localPath)) {
console.warn(`[x-article] Failed to copy image to clipboard`);
continue;
}
// Wait for clipboard to be fully ready
await sleep(1000);
// Delete placeholder using execCommand (more reliable than keyboard events for DraftJS)
console.log(`[x-article] Deleting placeholder...`);
const deleteResult = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return false;
// Try execCommand delete first
if (document.execCommand('delete', false)) return true;
// Fallback: replace selection with empty using insertText
document.execCommand('insertText', false, '');
return true;
})()`,
returnByValue: true,
}, { sessionId });
await sleep(500);
// Check that placeholder is no longer in editor (exact match, not substring)
const afterDelete = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');
if (!editor) return true;
const text = editor.innerText;
const placeholder = ${JSON.stringify(img.placeholder)};
// Use regex to find exact match (not followed by digit)
const regex = new RegExp(placeholder + '(?!\\\\d)');
return !regex.test(text);
})()`,
returnByValue: true,
}, { sessionId });
if (!afterDelete.result.value) {
console.warn(`[x-article] Placeholder may not have been deleted, trying dispatchEvent...`);
// Try selecting and deleting with InputEvent
await selectPlaceholder(1);
await sleep(300);
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (!editor) return;
editor.focus();
// Dispatch beforeinput and input events for deletion
const beforeEvent = new InputEvent('beforeinput', { inputType: 'deleteContentBackward', bubbles: true, cancelable: true });
editor.dispatchEvent(beforeEvent);
const inputEvent = new InputEvent('input', { inputType: 'deleteContentBackward', bubbles: true });
editor.dispatchEvent(inputEvent);
})()`,
}, { sessionId });
await sleep(500);
}
// Count existing image blocks before paste
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId });
// Focus editor to ensure cursor is in position
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (editor) editor.focus();
})()`,
}, { sessionId });
await sleep(300);
// Paste image using paste script (activates Chrome, sends real keystroke)
console.log(`[x-article] Pasting image...`);
if (pasteFromClipboard('Google Chrome', 5, 1000)) {
console.log(`[x-article] Image pasted: ${path.basename(img.localPath)}`);
} else {
console.warn(`[x-article] Failed to paste image after retries`);
}
// Verify image appeared in editor
console.log(`[x-article] Verifying image upload...`);
const expectedImgCount = imgCountBefore.result.value + 1;
let imgUploadOk = false;
const imgWaitStart = Date.now();
while (Date.now() - imgWaitStart < 15_000) {
const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId });
if (r.result.value >= expectedImgCount) {
imgUploadOk = true;
break;
}
await sleep(1000);
}
if (imgUploadOk) {
console.log(`[x-article] Image upload verified (${expectedImgCount} image block(s))`);
// Wait for DraftEditor DOM to stabilize after image insertion
await sleep(3000);
} else {
console.warn(`[x-article] Image upload not detected after 15s`);
if (i === 0) {
console.error('[x-article] First image paste failed. Run check-paste-permissions.ts to diagnose.');
}
}
}
console.log('[x-article] All images processed.');
// Final verification: check placeholder residue and image count
console.log('[x-article] Running post-composition verification...');
const finalEditorContent = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText || ''`,
returnByValue: true,
}, { sessionId });
const remainingPlaceholders: string[] = [];
for (const img of parsed.contentImages) {
const regex = new RegExp(img.placeholder + '(?!\\d)');
if (regex.test(finalEditorContent.result.value)) {
remainingPlaceholders.push(img.placeholder);
}
}
const finalImgCount = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId });
const expectedCount = parsed.contentImages.length;
const actualCount = finalImgCount.result.value;
if (remainingPlaceholders.length > 0 || actualCount < expectedCount) {
console.warn('[x-article] ⚠ POST-COMPOSITION CHECK FAILED:');
if (remainingPlaceholders.length > 0) {
console.warn(`[x-article] Remaining placeholders: ${remainingPlaceholders.join(', ')}`);
}
if (actualCount < expectedCount) {
console.warn(`[x-article] Image count: expected ${expectedCount}, found ${actualCount}`);
}
console.warn('[x-article] Please check the article before publishing.');
} else {
console.log(`[x-article] ✓ Verification passed: ${actualCount} image(s), no remaining placeholders.`);
}
}
// Before preview: blur editor to trigger save
console.log('[x-article] Triggering content save...');
await cdp.send('Runtime.evaluate', {
expression: `(() => {
// Blur editor to trigger any pending saves
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (editor) {
editor.blur();
}
// Also click elsewhere to ensure focus is lost
document.body.click();
})()`,
}, { sessionId });
await sleep(1500);
// Click Preview button
console.log('[x-article] Opening preview...');
const previewSelectors = JSON.stringify(I18N_SELECTORS.previewButton);
const previewClicked = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const selectors = ${previewSelectors};
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.click(); return true; }
}
return false;
})()`,
returnByValue: true,
}, { sessionId });
if (previewClicked.result.value) {
console.log('[x-article] Preview opened');
await sleep(3000);
} else {
console.log('[x-article] Preview button not found');
}
// Check for publish button
if (submit) {
console.log('[x-article] Publishing...');
const publishSelectors = JSON.stringify(I18N_SELECTORS.publishButton);
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const selectors = ${publishSelectors};
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && !el.disabled) { el.click(); return true; }
}
return false;
})()`,
}, { sessionId });
await sleep(3000);
console.log('[x-article] Article published!');
} else {
console.log('[x-article] Article composed (draft mode).');
console.log('[x-article] Browser remains open for manual review.');
}
} finally {
// Disconnect CDP but keep browser open
if (cdp) {
cdp.close();
}
// Don't kill Chrome - let user review and close manually
}
}
function printUsage(): never {
console.log(`Publish Markdown article to X (Twitter) Articles
Usage:
npx -y bun x-article.ts <markdown_file> [options]
Options:
--title <title> Override title
--cover <image> Override cover image
--submit Actually publish (default: draft only)
--profile <dir> Chrome profile directory
--help Show this help
Markdown frontmatter:
---
title: My Article Title
cover_image: /path/to/cover.jpg
---
Example:
npx -y bun x-article.ts article.md
npx -y bun x-article.ts article.md --cover ./hero.png
npx -y bun x-article.ts article.md --submit
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printUsage();
}
let markdownPath: string | undefined;
let title: string | undefined;
let coverImage: string | undefined;
let submit = false;
let profileDir: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--title' && args[i + 1]) {
title = args[++i];
} else if (arg === '--cover' && args[i + 1]) {
const raw = args[++i]!;
coverImage = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
} else if (arg === '--submit') {
submit = true;
} else if (arg === '--profile' && args[i + 1]) {
profileDir = args[++i];
} else if (!arg.startsWith('-')) {
markdownPath = arg;
}
}
if (!markdownPath) {
console.error('Error: Markdown file path required');
process.exit(1);
}
if (!fs.existsSync(markdownPath)) {
console.error(`Error: File not found: ${markdownPath}`);
process.exit(1);
}
await publishArticle({ markdownPath, title, coverImage, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
scripts/x-browser.ts
import fs from 'node:fs';
import { mkdir } from 'node:fs/promises';
import process from 'node:process';
import {
CHROME_CANDIDATES_FULL,
CdpConnection,
copyImageToClipboard,
findExistingChromeDebugPort,
getDefaultProfileDir,
gracefulKillChrome,
launchChrome,
openPageSession,
pasteFromClipboard,
sleep,
waitForXSessionPersistence,
waitForChromeDebugPort,
} from './x-utils.js';
const X_COMPOSE_URL = 'https://x.com/compose/post';
interface XBrowserOptions {
text?: string;
images?: string[];
submit?: boolean;
timeoutMs?: number;
profileDir?: string;
chromePath?: string;
}
export async function postToX(options: XBrowserOptions): Promise<void> {
const { text, images = [], submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
await mkdir(profileDir, { recursive: true });
const existingPort = await findExistingChromeDebugPort(profileDir);
const reusing = existingPort !== null;
let port = existingPort ?? 0;
let chrome: Awaited<ReturnType<typeof launchChrome>>['chrome'] | null = null;
if (!reusing) {
const launched = await launchChrome(X_COMPOSE_URL, profileDir, CHROME_CANDIDATES_FULL, options.chromePath);
port = launched.port;
chrome = launched.chrome;
}
if (reusing) console.log(`[x-browser] Reusing existing Chrome on port ${port}`);
else console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`);
let cdp: CdpConnection | null = null;
let sessionId: string | null = null;
let loggedInDuringRun = false;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_000 });
const page = await openPageSession({
cdp,
reusing,
url: X_COMPOSE_URL,
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
enablePage: true,
enableRuntime: true,
enableNetwork: true,
});
const activeSessionId = page.sessionId;
sessionId = activeSessionId;
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId: activeSessionId });
console.log('[x-browser] Waiting for X editor...');
await sleep(3000);
const waitForEditor = async (): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
returnByValue: true,
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(1000);
}
return false;
};
const editorFound = await waitForEditor();
if (!editorFound) {
console.log('[x-browser] Editor not found. Please log in to X in the browser window.');
console.log('[x-browser] Waiting for login...');
const loggedIn = await waitForEditor();
if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');
loggedInDuringRun = true;
}
if (text) {
console.log('[x-browser] Typing text...');
await cdp.send('Runtime.evaluate', {
expression: `
const editor = document.querySelector('[data-testid="tweetTextarea_0"]');
if (editor) {
editor.focus();
document.execCommand('insertText', false, ${JSON.stringify(text)});
}
`,
}, { sessionId: activeSessionId });
await sleep(500);
}
for (const imagePath of images) {
if (!fs.existsSync(imagePath)) {
console.warn(`[x-browser] Image not found: ${imagePath}`);
continue;
}
console.log(`[x-browser] Pasting image: ${imagePath}`);
if (!copyImageToClipboard(imagePath)) {
console.warn(`[x-browser] Failed to copy image to clipboard: ${imagePath}`);
continue;
}
// Count uploaded images before paste
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId: activeSessionId });
// Wait for clipboard to be ready
await sleep(500);
// Focus the editor
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
}, { sessionId: activeSessionId });
await sleep(200);
// Use paste script (handles platform differences, activates Chrome)
console.log('[x-browser] Pasting from clipboard...');
const pasteSuccess = pasteFromClipboard('Google Chrome', 5, 500);
if (!pasteSuccess) {
// Fallback to CDP (may not work for images on X)
console.log('[x-browser] Paste script failed, trying CDP fallback...');
const modifiers = process.platform === 'darwin' ? 4 : 2;
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId: activeSessionId });
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId: activeSessionId });
}
console.log('[x-browser] Verifying image upload...');
const expectedImgCount = imgCountBefore.result.value + 1;
let imgUploadOk = false;
const imgWaitStart = Date.now();
while (Date.now() - imgWaitStart < 15_000) {
const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId: activeSessionId });
if (r.result.value >= expectedImgCount) {
imgUploadOk = true;
break;
}
await sleep(1000);
}
if (imgUploadOk) {
console.log('[x-browser] Image upload verified');
} else {
console.warn('[x-browser] Image upload not detected after 15s. Run check-paste-permissions.ts to diagnose.');
}
}
if (submit) {
console.log('[x-browser] Submitting post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId: activeSessionId });
await sleep(2000);
console.log('[x-browser] Post submitted!');
} else {
console.log('[x-browser] Post composed. Please review and click the publish button in the browser.');
}
} finally {
let leaveChromeOpen = !submit;
if (chrome && submit && loggedInDuringRun && cdp && sessionId) {
console.log('[x-browser] Waiting for X session cookies to persist...');
const sessionReady = await waitForXSessionPersistence({ cdp, sessionId });
if (!sessionReady) {
console.warn('[x-browser] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.');
leaveChromeOpen = true;
}
}
if (cdp) {
cdp.close();
}
if (chrome) {
if (leaveChromeOpen) {
chrome.unref();
} else {
await gracefulKillChrome(chrome, port);
}
}
}
}
function printUsage(): never {
console.log(`Post to X (Twitter) using real Chrome browser
Usage:
npx -y bun x-browser.ts [options] [text]
Options:
--image <path> Add image (can be repeated, max 4)
--submit Actually post (default: preview only)
--profile <dir> Chrome profile directory
--help Show this help
Examples:
npx -y bun x-browser.ts "Hello from CLI!"
npx -y bun x-browser.ts "Check this out" --image ./screenshot.png
npx -y bun x-browser.ts "Post it!" --image a.png --image b.png --submit
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) printUsage();
const images: string[] = [];
let submit = false;
let profileDir: string | undefined;
const textParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--image' && args[i + 1]) {
images.push(args[++i]!);
} else if (arg === '--submit') {
submit = true;
} else if (arg === '--profile' && args[i + 1]) {
profileDir = args[++i];
} else if (!arg.startsWith('-')) {
textParts.push(arg);
}
}
const text = textParts.join(' ').trim() || undefined;
if (!text && images.length === 0) {
console.error('Error: Provide text or at least one image.');
process.exit(1);
}
await postToX({ text, images, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
scripts/x-quote.ts
import { mkdir } from 'node:fs/promises';
import process from 'node:process';
import {
CHROME_CANDIDATES_FULL,
CdpConnection,
findExistingChromeDebugPort,
getDefaultProfileDir,
gracefulKillChrome,
launchChrome,
openPageSession,
sleep,
waitForXSessionPersistence,
waitForChromeDebugPort,
} from './x-utils.js';
function extractTweetUrl(urlOrId: string): string | null {
// If it's already a full URL, normalize it
if (urlOrId.match(/(?:x\.com|twitter\.com)\/\w+\/status\/\d+/)) {
return urlOrId.replace(/twitter\.com/, 'x.com').split('?')[0];
}
return null;
}
interface QuoteOptions {
tweetUrl: string;
comment?: string;
submit?: boolean;
timeoutMs?: number;
profileDir?: string;
chromePath?: string;
}
export async function quotePost(options: QuoteOptions): Promise<void> {
const { tweetUrl, comment, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
await mkdir(profileDir, { recursive: true });
const existingPort = await findExistingChromeDebugPort(profileDir);
const reusing = existingPort !== null;
let port = existingPort ?? 0;
console.log(`[x-quote] Opening tweet: ${tweetUrl}`);
let chrome: Awaited<ReturnType<typeof launchChrome>>['chrome'] | null = null;
if (!reusing) {
const launched = await launchChrome(tweetUrl, profileDir, CHROME_CANDIDATES_FULL, options.chromePath);
port = launched.port;
chrome = launched.chrome;
}
if (reusing) console.log(`[x-quote] Reusing existing Chrome on port ${port}`);
else console.log(`[x-quote] Launching Chrome (profile: ${profileDir})`);
let cdp: CdpConnection | null = null;
let sessionId: string | null = null;
let targetId: string | null = null;
let loggedInDuringRun = false;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_000 });
const page = await openPageSession({
cdp,
reusing,
url: tweetUrl,
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
enablePage: true,
enableRuntime: true,
enableNetwork: true,
});
const activeSessionId = page.sessionId;
sessionId = activeSessionId;
targetId = page.targetId;
console.log('[x-quote] Waiting for tweet to load...');
await sleep(3000);
// Wait for retweet button to appear (indicates tweet loaded and user logged in)
const waitForRetweetButton = async (): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="retweet"]')`,
returnByValue: true,
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(1000);
}
return false;
};
const retweetFound = await waitForRetweetButton();
if (!retweetFound) {
console.log('[x-quote] Tweet not found or not logged in. Please log in to X in the browser window.');
console.log('[x-quote] Waiting for login...');
const loggedIn = await waitForRetweetButton();
if (!loggedIn) throw new Error('Timed out waiting for tweet. Please log in first or check the tweet URL.');
loggedInDuringRun = true;
}
// Click the retweet button
console.log('[x-quote] Clicking retweet button...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="retweet"]')?.click()`,
}, { sessionId: activeSessionId });
await sleep(1000);
// Wait for and click the "Quote" option in the menu
console.log('[x-quote] Selecting quote option...');
const waitForQuoteOption = async (): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < 10_000) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')`,
returnByValue: true,
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(200);
}
return false;
};
const quoteOptionFound = await waitForQuoteOption();
if (!quoteOptionFound) {
throw new Error('Quote option not found. The menu may not have opened.');
}
// Click the quote option (second menu item)
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')?.click()`,
}, { sessionId: activeSessionId });
await sleep(2000);
// Wait for the quote compose dialog
console.log('[x-quote] Waiting for quote compose dialog...');
const waitForQuoteDialog = async (): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < 10_000) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
returnByValue: true,
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(200);
}
return false;
};
const dialogFound = await waitForQuoteDialog();
if (!dialogFound) {
throw new Error('Quote compose dialog not found.');
}
// Type the comment if provided
if (comment) {
console.log('[x-quote] Typing comment...');
// Use CDP Input.insertText for more reliable text insertion
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
}, { sessionId: activeSessionId });
await sleep(200);
await cdp.send('Input.insertText', {
text: comment,
}, { sessionId: activeSessionId });
await sleep(500);
}
if (submit) {
console.log('[x-quote] Submitting quote post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId: activeSessionId });
await sleep(2000);
console.log('[x-quote] Quote post submitted!');
} else {
console.log('[x-quote] Quote composed (preview mode). Add --submit to post.');
console.log('[x-quote] Browser will stay open for 30 seconds for preview...');
await sleep(30_000);
}
} finally {
let leaveChromeOpen = false;
if (chrome && loggedInDuringRun && cdp && sessionId) {
console.log('[x-quote] Waiting for X session cookies to persist...');
const sessionReady = await waitForXSessionPersistence({ cdp, sessionId });
if (!sessionReady) {
console.warn('[x-quote] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.');
leaveChromeOpen = true;
}
}
if (cdp) {
if (reusing && targetId) {
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
}
cdp.close();
}
if (chrome) {
if (leaveChromeOpen) {
chrome.unref();
} else {
await gracefulKillChrome(chrome, port);
}
}
}
}
function printUsage(): never {
console.log(`Quote a tweet on X (Twitter) using real Chrome browser
Usage:
npx -y bun x-quote.ts <tweet-url> [options] [comment]
Options:
--submit Actually post (default: preview only)
--profile <dir> Chrome profile directory
--help Show this help
Examples:
npx -y bun x-quote.ts https://x.com/user/status/123456789 "Great insight!"
npx -y bun x-quote.ts https://x.com/user/status/123456789 "I agree!" --submit
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) printUsage();
let tweetUrl: string | undefined;
let submit = false;
let profileDir: string | undefined;
const commentParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--submit') {
submit = true;
} else if (arg === '--profile' && args[i + 1]) {
profileDir = args[++i];
} else if (!arg.startsWith('-')) {
// First non-option argument is the tweet URL
if (!tweetUrl && arg.match(/(?:x\.com|twitter\.com)\/\w+\/status\/\d+/)) {
tweetUrl = extractTweetUrl(arg) ?? undefined;
} else {
commentParts.push(arg);
}
}
}
if (!tweetUrl) {
console.error('Error: Please provide a tweet URL.');
console.error('Example: npx -y bun x-quote.ts https://x.com/user/status/123456789 "Your comment"');
process.exit(1);
}
const comment = commentParts.join(' ').trim() || undefined;
await quotePost({ tweetUrl, comment, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
scripts/x-utils.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import {
buildXSessionCookieMap,
hasChromeLockArtifacts,
hasRequiredXSessionCookies,
shouldRetryChromeLaunch,
} from "./x-utils.ts";
test("hasChromeLockArtifacts detects Chrome singleton artifacts", () => {
assert.equal(hasChromeLockArtifacts(["SingletonSocket"]), true);
assert.equal(hasChromeLockArtifacts(["chrome.pid"]), true);
assert.equal(hasChromeLockArtifacts(["Cookies", "Preferences"]), false);
});
test("shouldRetryChromeLaunch only retries when no live owner exists", () => {
assert.equal(
shouldRetryChromeLaunch({ lockArtifactsPresent: true, hasLiveOwner: false }),
true,
);
assert.equal(
shouldRetryChromeLaunch({ lockArtifactsPresent: true, hasLiveOwner: true }),
false,
);
assert.equal(
shouldRetryChromeLaunch({ lockArtifactsPresent: false, hasLiveOwner: false }),
false,
);
});
test("buildXSessionCookieMap keeps only non-empty cookies", () => {
assert.deepEqual(
buildXSessionCookieMap([
{ name: "auth_token", value: "auth" },
{ name: "ct0", value: "csrf" },
{ name: "twid", value: "u=123" },
{ name: "ct0", value: "" },
{ name: "", value: "ignored" },
{ name: "gt", value: undefined },
]),
{
auth_token: "auth",
ct0: "csrf",
twid: "u=123",
},
);
});
test("hasRequiredXSessionCookies requires auth_token and ct0", () => {
assert.equal(hasRequiredXSessionCookies({ auth_token: "auth" }), false);
assert.equal(hasRequiredXSessionCookies({ ct0: "csrf" }), false);
assert.equal(hasRequiredXSessionCookies({ auth_token: "auth", ct0: "csrf" }), true);
});
scripts/x-utils.ts
import { execSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import {
CdpConnection,
findChromeExecutable as findChromeExecutableBase,
findExistingChromeDebugPort as findExistingChromeDebugPortBase,
getFreePort as getFreePortBase,
gracefulKillChrome,
killChrome,
launchChrome as launchChromeBase,
openPageSession,
resolveSharedChromeProfileDir,
sleep,
waitForChromeDebugPort,
type PlatformCandidates,
} from 'baoyu-chrome-cdp';
export { CdpConnection, gracefulKillChrome, killChrome, openPageSession, sleep, waitForChromeDebugPort };
export type { PlatformCandidates } from 'baoyu-chrome-cdp';
const X_SESSION_URLS = ['https://x.com/', 'https://twitter.com/'] as const;
const REQUIRED_X_SESSION_COOKIES = ['auth_token', 'ct0'] as const;
interface CookieLike {
name?: string;
value?: string | null;
}
interface NetworkGetCookiesResult {
cookies?: CookieLike[];
}
export const CHROME_CANDIDATES_BASIC: PlatformCandidates = {
darwin: [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
],
win32: [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
],
default: [
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
],
};
export const CHROME_CANDIDATES_FULL: PlatformCandidates = {
darwin: [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
],
win32: [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
],
default: [
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
'/usr/bin/microsoft-edge',
],
};
export function findChromeExecutable(candidates: PlatformCandidates): string | undefined {
return findChromeExecutableBase({
candidates,
envNames: ['X_BROWSER_CHROME_PATH'],
});
}
let _wslHome: string | null | undefined;
function getWslWindowsHome(): string | null {
if (_wslHome !== undefined) return _wslHome;
if (!process.env.WSL_DISTRO_NAME) { _wslHome = null; return null; }
try {
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', { encoding: 'utf-8', timeout: 5000 }).trim().replace(/\r/g, '');
_wslHome = execSync(`wslpath -u "${raw}"`, { encoding: 'utf-8', timeout: 5000 }).trim() || null;
} catch { _wslHome = null; }
return _wslHome;
}
export function getDefaultProfileDir(): string {
return resolveSharedChromeProfileDir({
envNames: ['BAOYU_CHROME_PROFILE_DIR', 'X_BROWSER_PROFILE_DIR'],
wslWindowsHome: getWslWindowsHome(),
});
}
export async function getFreePort(): Promise<number> {
return await getFreePortBase('X_BROWSER_DEBUG_PORT');
}
export async function findExistingChromeDebugPort(profileDir: string): Promise<number | null> {
return await findExistingChromeDebugPortBase({ profileDir });
}
const CHROME_LOCK_FILES = ['SingletonLock', 'SingletonSocket', 'SingletonCookie', 'chrome.pid'] as const;
export function hasChromeLockArtifacts(entries: readonly string[]): boolean {
return CHROME_LOCK_FILES.some((name) => entries.includes(name));
}
export function shouldRetryChromeLaunch(options: {
lockArtifactsPresent: boolean;
hasLiveOwner: boolean;
}): boolean {
return options.lockArtifactsPresent && !options.hasLiveOwner;
}
export function buildXSessionCookieMap(cookies: readonly CookieLike[]): Record<string, string> {
const cookieMap: Record<string, string> = {};
for (const cookie of cookies) {
const name = cookie.name?.trim();
const value = cookie.value?.trim();
if (!name || !value) {
continue;
}
cookieMap[name] = value;
}
return cookieMap;
}
export function hasRequiredXSessionCookies(cookieMap: Record<string, string>): boolean {
return REQUIRED_X_SESSION_COOKIES.every((name) => Boolean(cookieMap[name]));
}
export async function readXSessionCookieMap(
cdp: CdpConnection,
sessionId?: string,
): Promise<Record<string, string>> {
const { cookies } = await cdp.send<NetworkGetCookiesResult>(
'Network.getCookies',
{ urls: [...X_SESSION_URLS] },
{
sessionId,
timeoutMs: 5_000,
},
);
return buildXSessionCookieMap(cookies ?? []);
}
export async function waitForXSessionPersistence(options: {
cdp: CdpConnection;
sessionId?: string;
timeoutMs?: number;
pollIntervalMs?: number;
}): Promise<boolean> {
const timeoutMs = options.timeoutMs ?? 15_000;
const pollIntervalMs = options.pollIntervalMs ?? 1_000;
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const cookieMap = await readXSessionCookieMap(options.cdp, options.sessionId).catch(() => ({}));
if (hasRequiredXSessionCookies(cookieMap)) {
return true;
}
await sleep(pollIntervalMs);
}
return false;
}
function cleanStaleLockFiles(profileDir: string): void {
for (const name of CHROME_LOCK_FILES) {
try { fs.unlinkSync(path.join(profileDir, name)); } catch {}
}
}
function hasLiveChromeOwner(profileDir: string): boolean {
if (process.platform === 'win32') return false;
try {
const result = spawnSync('ps', ['aux'], {
encoding: 'utf8',
timeout: 5000,
});
if (result.status !== 0 || !result.stdout) return false;
return result.stdout.split('\n').some((line) => line.includes(`--user-data-dir=${profileDir}`));
} catch {
return false;
}
}
async function listProfileDirEntries(profileDir: string): Promise<string[]> {
try {
return await fs.promises.readdir(profileDir);
} catch {
return [];
}
}
async function launchChromeOnce(
url: string,
profileDir: string,
chromePath: string,
): Promise<{ chrome: Awaited<ReturnType<typeof launchChromeBase>>; port: number }> {
const port = await getFreePort();
const chrome = await launchChromeBase({
chromePath,
profileDir,
port,
url,
extraArgs: ['--start-maximized'],
});
try {
await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
chrome.unref();
return { chrome, port };
} catch (error) {
killChrome(chrome);
throw error;
}
}
export async function launchChrome(
url: string,
profileDir: string,
candidates: PlatformCandidates,
chromePathOverride?: string,
): Promise<{ chrome: Awaited<ReturnType<typeof launchChromeBase>>; port: number }> {
const chromePath = chromePathOverride?.trim() || findChromeExecutable(candidates);
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
try {
return await launchChromeOnce(url, profileDir, chromePath);
} catch (error) {
const entries = await listProfileDirEntries(profileDir);
const shouldRetry = shouldRetryChromeLaunch({
lockArtifactsPresent: hasChromeLockArtifacts(entries),
hasLiveOwner: hasLiveChromeOwner(profileDir),
});
if (!shouldRetry) throw error;
cleanStaleLockFiles(profileDir);
return await launchChromeOnce(url, profileDir, chromePath);
}
}
export function getScriptDir(): string {
return path.dirname(fileURLToPath(import.meta.url));
}
function runBunScript(scriptPath: string, args: string[]): boolean {
const result = spawnSync('npx', ['-y', 'bun', scriptPath, ...args], { stdio: 'inherit' });
return result.status === 0;
}
export function copyImageToClipboard(imagePath: string): boolean {
const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
return runBunScript(copyScript, ['image', imagePath]);
}
export function copyHtmlToClipboard(htmlPath: string): boolean {
const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
return runBunScript(copyScript, ['html', '--file', htmlPath]);
}
export function pasteFromClipboard(targetApp?: string, retries = 3, delayMs = 500): boolean {
const pasteScript = path.join(getScriptDir(), 'paste-from-clipboard.ts');
const args = ['--retries', String(retries), '--delay', String(delayMs)];
if (targetApp) args.push('--app', targetApp);
return runBunScript(pasteScript, args);
}
scripts/x-video.ts
import fs from 'node:fs';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import {
CHROME_CANDIDATES_FULL,
CdpConnection,
findExistingChromeDebugPort,
getDefaultProfileDir,
gracefulKillChrome,
launchChrome,
openPageSession,
sleep,
waitForXSessionPersistence,
waitForChromeDebugPort,
} from './x-utils.js';
const X_COMPOSE_URL = 'https://x.com/compose/post';
interface XVideoOptions {
text?: string;
videoPath: string;
submit?: boolean;
timeoutMs?: number;
profileDir?: string;
chromePath?: string;
}
export async function postVideoToX(options: XVideoOptions): Promise<void> {
const { text, videoPath, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
if (!fs.existsSync(videoPath)) throw new Error(`Video not found: ${videoPath}`);
const absVideoPath = path.resolve(videoPath);
console.log(`[x-video] Video: ${absVideoPath}`);
await mkdir(profileDir, { recursive: true });
const existingPort = await findExistingChromeDebugPort(profileDir);
const reusing = existingPort !== null;
let port = existingPort ?? 0;
let chrome: Awaited<ReturnType<typeof launchChrome>>['chrome'] | null = null;
if (!reusing) {
const launched = await launchChrome(X_COMPOSE_URL, profileDir, CHROME_CANDIDATES_FULL, options.chromePath);
port = launched.port;
chrome = launched.chrome;
}
if (reusing) console.log(`[x-video] Reusing existing Chrome on port ${port}`);
else console.log(`[x-video] Launching Chrome (profile: ${profileDir})`);
let cdp: CdpConnection | null = null;
let sessionId: string | null = null;
let targetId: string | null = null;
let loggedInDuringRun = false;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 30_000 });
const page = await openPageSession({
cdp,
reusing,
url: X_COMPOSE_URL,
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
enablePage: true,
enableRuntime: true,
enableDom: true,
enableNetwork: true,
});
const activeSessionId = page.sessionId;
sessionId = activeSessionId;
targetId = page.targetId;
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId: activeSessionId });
console.log('[x-video] Waiting for X editor...');
await sleep(3000);
const waitForEditor = async (): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
returnByValue: true,
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(1000);
}
return false;
};
const editorFound = await waitForEditor();
if (!editorFound) {
console.log('[x-video] Editor not found. Please log in to X in the browser window.');
console.log('[x-video] Waiting for login...');
const loggedIn = await waitForEditor();
if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');
loggedInDuringRun = true;
}
// Upload video FIRST (before typing text to avoid text being cleared)
console.log('[x-video] Uploading video...');
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId: activeSessionId });
const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
nodeId: root.nodeId,
selector: 'input[type="file"][accept*="video"], input[data-testid="fileInput"], input[type="file"]',
}, { sessionId: activeSessionId });
if (!nodeId || nodeId === 0) {
throw new Error('Could not find file input for video upload.');
}
await cdp.send('DOM.setFileInputFiles', {
nodeId,
files: [absVideoPath],
}, { sessionId: activeSessionId });
console.log('[x-video] Video file set, uploading in background...');
// Wait a moment for upload to start, then type text while video processes
await sleep(2000);
// Type text while video uploads in background
if (text) {
console.log('[x-video] Typing text...');
await cdp.send('Runtime.evaluate', {
expression: `
const editor = document.querySelector('[data-testid="tweetTextarea_0"]');
if (editor) {
editor.focus();
document.execCommand('insertText', false, ${JSON.stringify(text)});
}
`,
}, { sessionId: activeSessionId });
await sleep(500);
}
// Wait for video to finish processing by checking if tweet button is enabled
console.log('[x-video] Waiting for video processing...');
const waitForVideoReady = async (maxWaitMs = 180_000): Promise<boolean> => {
const start = Date.now();
let dots = 0;
while (Date.now() - start < maxWaitMs) {
const result = await cdp!.send<{ result: { value: { hasMedia: boolean; buttonEnabled: boolean } } }>('Runtime.evaluate', {
expression: `(() => {
const hasMedia = !!document.querySelector('[data-testid="attachments"] video, [data-testid="videoPlayer"], video');
const tweetBtn = document.querySelector('[data-testid="tweetButton"]');
const buttonEnabled = tweetBtn && !tweetBtn.disabled && tweetBtn.getAttribute('aria-disabled') !== 'true';
return { hasMedia, buttonEnabled };
})()`,
returnByValue: true,
}, { sessionId: activeSessionId });
const { hasMedia, buttonEnabled } = result.result.value;
if (hasMedia && buttonEnabled) {
console.log('');
return true;
}
process.stdout.write('.');
dots++;
if (dots % 60 === 0) console.log(''); // New line every 60 dots
await sleep(2000);
}
console.log('');
return false;
};
const videoReady = await waitForVideoReady();
if (videoReady) {
console.log('[x-video] Video ready!');
} else {
console.log('[x-video] Video may still be processing. Please check browser window.');
}
if (submit) {
console.log('[x-video] Submitting post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId: activeSessionId });
await sleep(5000);
console.log('[x-video] Post submitted!');
} else {
console.log('[x-video] Post composed (preview mode). Add --submit to post.');
console.log('[x-video] Browser stays open for review.');
}
} finally {
let leaveChromeOpen = !submit;
if (chrome && submit && loggedInDuringRun && cdp && sessionId) {
console.log('[x-video] Waiting for X session cookies to persist...');
const sessionReady = await waitForXSessionPersistence({ cdp, sessionId });
if (!sessionReady) {
console.warn('[x-video] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.');
leaveChromeOpen = true;
}
}
if (cdp) {
if (reusing && submit && targetId) {
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
}
cdp.close();
}
if (chrome && submit) {
if (leaveChromeOpen) {
chrome.unref();
} else {
await gracefulKillChrome(chrome, port);
}
}
}
}
function printUsage(): never {
console.log(`Post video to X (Twitter) using real Chrome browser
Usage:
npx -y bun x-video.ts [options] --video <path> [text]
Options:
--video <path> Video file path (required, supports mp4/mov/webm)
--submit Actually post (default: preview only)
--profile <dir> Chrome profile directory
--help Show this help
Examples:
npx -y bun x-video.ts --video ./clip.mp4 "Check out this video!"
npx -y bun x-video.ts --video ./demo.mp4 --submit
npx -y bun x-video.ts --video ./video.mp4 "Multi-line text
works too"
Notes:
- Video is uploaded first, then text is added (to avoid text being cleared)
- Video processing may take 30-60 seconds depending on file size
- Maximum video length on X: 140 seconds (regular) or 60 min (Premium)
- Supported formats: MP4, MOV, WebM
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) printUsage();
let videoPath: string | undefined;
let submit = false;
let profileDir: string | undefined;
const textParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--video' && args[i + 1]) {
videoPath = args[++i]!;
} else if (arg === '--submit') {
submit = true;
} else if (arg === '--profile' && args[i + 1]) {
profileDir = args[++i];
} else if (!arg.startsWith('-')) {
textParts.push(arg);
}
}
const text = textParts.join(' ').trim() || undefined;
if (!videoPath) {
console.error('Error: --video <path> is required.');
printUsage();
}
await postVideoToX({ text, videoPath, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
SKILL.md
---
name: baoyu-post-to-x
description: Posts content and articles to X (Twitter). Supports regular posts with images/videos and X Articles (long-form Markdown). In Codex, honor explicit requests for the Codex Chrome plugin/@chrome by using the Chrome Extension workflow; otherwise use Chrome Computer Use when available and fall back to real Chrome CDP scripts only when allowed. Use when user asks to "post to X", "tweet", "publish to Twitter", or "share on X".
version: 1.58.1
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-x
requires:
anyBins:
- bun
- npx
---
# Post to X (Twitter)
Posts text, images, videos, and long-form articles to X via a real Chrome browser.
In Codex, do not conflate these browser paths:
- **Codex Chrome plugin / `@chrome` / Chrome Extension**: use the bundled `chrome:Chrome` skill and its Node REPL browser client. This is required whenever the user says "Codex Chrome plugin", "Codex 自带的 Chrome 插件", `@chrome`, or similar.
- **Chrome Computer Use**: use `mcp__computer_use__.*` against the visible Google Chrome UI only when the user asks for Computer Use or no Chrome-plugin preference is stated and Computer Use is available.
- **CDP script mode**: use only as a fallback when the selected mode is unavailable or the user explicitly asks for CDP/script mode.
## Script Directory
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
**Agent Execution Instructions**:
1. Determine this SKILL.md file's directory path as `{baseDir}`
2. Script path = `{baseDir}/scripts/<script-name>.ts`
3. Replace all `{baseDir}` in this document with the actual path
4. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun
**Script Reference**:
| Script | Purpose |
|--------|---------|
| `scripts/x-browser.ts` | Regular posts (text + images), CDP fallback |
| `scripts/x-video.ts` | Video posts (text + video), CDP fallback |
| `scripts/x-quote.ts` | Quote tweet with comment, CDP fallback |
| `scripts/x-article.ts` | Long-form article publishing (Markdown), CDP fallback |
| `scripts/md-to-html.ts` | Markdown → HTML conversion |
| `scripts/copy-to-clipboard.ts` | Copy content to clipboard |
| `scripts/paste-from-clipboard.ts` | Send real paste keystroke |
| `scripts/check-paste-permissions.ts` | Verify environment & permissions |
## Execution Mode Selection (Required)
Choose exactly one mode before interacting with X:
1. If the user explicitly asks for the Codex Chrome plugin, `@chrome`, the Chrome extension, or "Codex 自带的 Chrome 插件", use **Codex Chrome Plugin Mode**. Do not call Computer Use first.
2. If the user explicitly asks for Chrome Computer Use, use **Chrome Computer Use Mode**. Do not fall back to CDP, Playwright, the in-app Browser, or the Chrome plugin without telling the user and getting approval.
3. If the user explicitly asks for CDP/script mode, use **CDP Script Mode**.
4. Otherwise, prefer **Chrome Computer Use Mode**. For Markdown **X Articles with local content images**, use the tested X editor flow: insert each body image from the toolbar (`Insert` -> `Media` -> dialog icon button `Add photos or video`) at its placeholder, then delete the placeholder text. Use CDP Script Mode only when the selected browser-control mode is unavailable or the UI upload/selection flow is unreliable.
Never use the in-app Browser for X publishing workflows.
## Codex Chrome Plugin Mode
Use this mode whenever the user requests the Codex Chrome plugin, `@chrome`, or the Chrome Extension path. This uses the user's real Chrome profile and X login through the bundled Chrome plugin, not Computer Use and not CDP.
**Setup**
1. Load the `chrome:Chrome` skill before browser work.
2. Use `tool_search` for `node_repl js` if the Node REPL `js` tool is not already visible.
3. Initialize the Chrome browser client exactly as the Chrome skill specifies, then run a lightweight call such as `browser.user.openTabs()` to verify the extension connection.
4. If the first lightweight call fails, wait 2 seconds and retry once. If it still fails, follow the Chrome skill's extension checks and recovery steps. If checks pass but communication still fails, ask the user before opening a new Chrome window. Do not switch to Computer Use or CDP silently.
**General rules**
- Use the Chrome plugin's `browser.tabs.*`, `tab.playwright.*`, `tab.cua.*`, and file chooser APIs for X UI actions.
- Shell commands are allowed for Markdown preprocessing and rich-HTML clipboard preparation. For X Article body images, do not rely on image clipboard paste; use the editor's `Insert` -> `Media` upload flow.
- If a file upload fails with `Not allowed`, tell the user: `To enable file upload, go to chrome://extensions in Chrome, click Details under the Codex extension, and enable "Allow access to file URLs." See https://developers.openai.com/codex/app/chrome-extension#upload-files for details.`
- If the Chrome plugin reports `native pipe is closed`, retry the lightweight browser call once after 2 seconds, then run the Chrome skill health checks. If Chrome is running, the extension is enabled, and the native host manifest is correct, ask permission to open a new Chrome window and retry. Do not keep sending browser actions through the broken pipe.
- Never click `Publish`, `Post`, or any externally visible submit action without explicit final confirmation from the user in the current conversation.
**X Articles**
1. Convert Markdown and keep the image map:
```bash
${BUN_X} {baseDir}/scripts/md-to-html.ts article.md --save-html /tmp/x-article-body.html > /tmp/x-article.json
```
2. Read the JSON output for `title`, `coverImage`, and `contentImages` (`placeholder` → `localPath`).
3. Open or create the article draft at `https://x.com/compose/articles`.
4. Upload the cover with the Chrome plugin file chooser flow. If upload is blocked by extension permissions, stop and report the exact permission fix above.
5. Fill the title, then copy rich HTML:
```bash
${BUN_X} {baseDir}/scripts/copy-to-clipboard.ts html --file /tmp/x-article-body.html
```
6. Paste into the article body with a real paste keystroke through the Chrome plugin. On macOS use `Meta+V`.
7. Verify the editor text contains the article body and `XIMGPH_` placeholders. Do not rely on `tab.clipboard.readText()` as proof of the system clipboard after shell clipboard writes; on macOS verify with `pbpaste` if needed.
8. For each `contentImages` item in placeholder order:
- Locate the visible placeholder text (`XIMGPH_N`) and click it to place the caret there.
- Open the toolbar menu `Insert` -> `Media`.
- In the modal, click the icon button with `aria-label="Add photos or video"`; do not click the text/dropzone or hidden file input.
- Use the file chooser to upload that image's `localPath`.
- After the image appears, if `XIMGPH_N` remains above it, select exactly that placeholder and press `Delete` first. Use `Backspace` only if `Delete` fails and the selected text is confirmed to be exactly the placeholder.
- Verify the placeholder count for that `XIMGPH_N` is `0`.
9. Open Preview and verify title, cover, body, links, and images.
10. Ask for explicit confirmation before clicking `Publish`.
## Preferences (EXTEND.md)
Check EXTEND.md in priority order — the first one found wins:
| Priority | Path | Scope |
|----------|------|-------|
| 1 | `.baoyu-skills/baoyu-post-to-x/EXTEND.md` | Project |
| 2 | `${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-post-to-x/EXTEND.md` | XDG |
| 3 | `$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md` | User home |
If none found, use defaults.
**EXTEND.md supports**: Default Chrome profile
## Prerequisites
- Google Chrome or Chromium
- `bun` runtime
- First run: log in to X manually (session saved)
## Pre-flight Check (Optional)
Before first use, suggest running the environment check. User can skip if they prefer.
```bash
${BUN_X} {baseDir}/scripts/check-paste-permissions.ts
```
Checks: Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, Chrome conflicts.
**If any check fails**, provide fix guidance per item:
| Check | Fix |
|-------|-----|
| Chrome | Install Chrome or set `X_BROWSER_CHROME_PATH` env var |
| Profile dir | Shared profile at `baoyu-skills/chrome-profile` (see CLAUDE.md Chrome Profile section) |
| Bun runtime | `brew install oven-sh/bun/bun` (macOS) or `npm install -g bun` |
| Accessibility (macOS) | System Settings → Privacy & Security → Accessibility → enable terminal app |
| Clipboard copy | Ensure Swift/AppKit available (macOS Xcode CLI tools: `xcode-select --install`) |
| Paste keystroke (macOS) | Same as Accessibility fix above |
| Paste keystroke (Linux) | Install `xdotool` (X11) or `ydotool` (Wayland) |
## References
- **Regular Posts**: See `references/regular-posts.md` for manual workflow, troubleshooting, and technical details
- **X Articles**: See `references/articles.md` for long-form article publishing guide
---
## Chrome Computer Use Mode
Use this mode when the user explicitly asks for Chrome Computer Use, or when no Chrome-plugin preference is stated and Codex can control `Google Chrome` with Computer Use. This uses the user's existing Chrome window, cookies, login, extensions, and X session.
**General rules**:
- Start each assistant turn that controls Chrome by calling `get_app_state` for `Google Chrome`.
- Prefer element-index actions when available; use coordinates only for editor text selection or drag selection.
- Do not use the in-app Browser, the Chrome plugin, Playwright, or CDP for X UI actions in this mode unless the user approves a mode change.
- Never click `Publish`, `Post`, or any externally visible submit action without an explicit final confirmation from the user in the current conversation.
**Regular posts**:
1. Open or navigate Chrome to `https://x.com/compose/post`.
2. Type the post text into the composer using Computer Use.
3. For each image, run:
```bash
${BUN_X} {baseDir}/scripts/copy-to-clipboard.ts image /absolute/path/to/image.png
```
4. Paste with Computer Use (`super+v` on macOS, `control+v` on Windows/Linux), then wait until X finishes uploading media.
5. Ask for confirmation before clicking `Post`.
**Video posts**:
1. Open or navigate Chrome to `https://x.com/compose/post`.
2. Type the post text into the composer.
3. Use the visible media upload/file picker UI to attach the video.
4. Wait for upload and processing to complete.
5. Ask for confirmation before clicking `Post`.
**Quote tweets**:
1. Open the tweet URL in Chrome.
2. Use the visible quote/repost UI to choose Quote.
3. Type the comment.
4. Ask for confirmation before clicking `Post`.
**X Articles**:
1. Convert Markdown and keep the image map:
```bash
${BUN_X} {baseDir}/scripts/md-to-html.ts article.md --save-html /tmp/x-article-body.html > /tmp/x-article.json
```
2. Read the JSON output for `title`, `coverImage`, and `contentImages` (`placeholder` → `localPath`).
3. In Chrome, open `https://x.com/compose/articles`, create or open the draft, upload the cover if present, and fill the title.
4. Copy rich HTML to the clipboard:
```bash
${BUN_X} {baseDir}/scripts/copy-to-clipboard.ts html --file /tmp/x-article-body.html
```
5. Paste into the article body with Computer Use.
6. For each `contentImages` entry in placeholder order:
- Locate the exact visible placeholder text such as `XIMGPH_3` and click it to set the insertion point.
- Open the toolbar `Insert` dropdown, choose `Media`, then click the modal's icon button labeled `Add photos or video`.
- Use the native file picker to choose the image's `localPath`.
- Wait until the image block appears and any upload activity is finished.
- If the placeholder remains above the inserted image, reselect exactly that placeholder text and press `Delete` first. Use `Backspace` only if `Delete` fails and the selected text is confirmed to be exactly the placeholder.
7. Verify no `XIMGPH_` placeholders remain and the expected images appear.
8. Open Preview and verify title, cover, body, links, and images.
9. Ask for explicit confirmation before clicking `Publish`.
If Computer Use selection, toolbar upload, or file-picker control becomes unreliable, stop and report the blocker instead of switching to the Chrome plugin or CDP silently.
---
## CDP Script Mode (Fallback)
Use the script sections below only when the selected browser-control mode is unavailable, unreliable, or explicitly not requested. These scripts launch or reuse a real Chrome instance via CDP and keep the browser open for review.
Do not use CDP Script Mode when the user explicitly requires the Codex Chrome plugin or Chrome Computer Use unless the user approves the fallback after you explain the blocker.
---
## Post Type Selection
Unless the user explicitly specifies the post type:
- **Plain text** + within 10,000 characters → **Regular Post** (Premium members support up to 10,000 characters, non-Premium: 280)
- **Markdown file** (.md) → **X Article**
## Regular Posts
```bash
${BUN_X} {baseDir}/scripts/x-browser.ts "Hello!" --image ./photo.png
```
**Parameters**:
| Parameter | Description |
|-----------|-------------|
| `<text>` | Post content (positional) |
| `--image <path>` | Image file (repeatable, max 4) |
| `--profile <dir>` | Custom Chrome profile |
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
**Codex mode note**: If the user explicitly requested the Codex Chrome plugin, use **Codex Chrome Plugin Mode**. Otherwise, if Chrome Computer Use is enabled, use **Chrome Computer Use Mode** instead of running `x-browser.ts`.
---
## Video Posts
Text + video file.
```bash
${BUN_X} {baseDir}/scripts/x-video.ts "Check this out!" --video ./clip.mp4
```
**Parameters**:
| Parameter | Description |
|-----------|-------------|
| `<text>` | Post content (positional) |
| `--video <path>` | Video file (MP4, MOV, WebM) |
| `--profile <dir>` | Custom Chrome profile |
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
**Codex mode note**: If the user explicitly requested the Codex Chrome plugin, use **Codex Chrome Plugin Mode**. Otherwise, if Chrome Computer Use is enabled, use **Chrome Computer Use Mode** instead of running `x-video.ts`.
**Limits**: Regular 140s max, Premium 60min. Processing: 30-60s.
---
## Quote Tweets
Quote an existing tweet with comment.
```bash
${BUN_X} {baseDir}/scripts/x-quote.ts https://x.com/user/status/123 "Great insight!"
```
**Parameters**:
| Parameter | Description |
|-----------|-------------|
| `<tweet-url>` | URL to quote (positional) |
| `<comment>` | Comment text (positional, optional) |
| `--profile <dir>` | Custom Chrome profile |
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
**Codex mode note**: If the user explicitly requested the Codex Chrome plugin, use **Codex Chrome Plugin Mode**. Otherwise, if Chrome Computer Use is enabled, use **Chrome Computer Use Mode** instead of running `x-quote.ts`.
---
## X Articles
Long-form Markdown articles (requires X Premium).
```bash
${BUN_X} {baseDir}/scripts/x-article.ts article.md
${BUN_X} {baseDir}/scripts/x-article.ts article.md --cover ./cover.jpg
```
**Parameters**:
| Parameter | Description |
|-----------|-------------|
| `<markdown>` | Markdown file (positional) |
| `--cover <path>` | Cover image |
| `--title <text>` | Override title |
**Frontmatter**: `title`, `cover_image` supported in YAML front matter.
**Codex mode note**: If the user explicitly requested the Codex Chrome plugin, follow **Codex Chrome Plugin Mode** above. If the user explicitly requested Chrome Computer Use, follow **Chrome Computer Use Mode**. Otherwise, prefer Chrome Computer Use; for Markdown articles with local content images, use the toolbar `Insert` -> `Media` image-upload workflow before falling back to `x-article.ts` in **CDP Script Mode**.
**CDP fallback note**: The script opens browser with article filled in. User reviews and publishes manually unless `--submit` is used.
**Publish safety**: Do not use `--submit` or click `Publish` unless the user explicitly confirms the final public publish action.
**Post-Composition Check**: The script automatically verifies after all images are inserted:
- Remaining `XIMGPH_` placeholders in editor content
- Expected vs actual image count
If the check fails (warnings in output), alert the user with the specific issues before they publish.
---
## Troubleshooting
### Chrome debug port not ready
CDP fallback only: if a script fails with `Chrome debug port not ready` or `Unable to connect`, kill existing Chrome CDP instances first, then retry:
```bash
pkill -f "Chrome.*remote-debugging-port" 2>/dev/null; pkill -f "Chromium.*remote-debugging-port" 2>/dev/null; sleep 2
```
**Important**: This should be done automatically — when encountering this error, kill Chrome CDP instances and retry the command without asking the user.
## Notes
- First run: manual login required (session persists)
- In Codex Chrome Plugin Mode and Chrome Computer Use Mode, use the user's existing Chrome session and do not launch a separate CDP profile
- CDP scripts only fill content into the browser by default; user must review and publish manually unless `--submit` is explicitly used
- Cross-platform: macOS, Linux, Windows
## Extension Support
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.