
Canghe Post To X
- 459 installs
- 424 repo stars
- Updated June 8, 2026
- freestylefly/canghe-skills
canghe-post-to-x is an agent skill that composes and publishes tweets or threads to X.com from an agent workflow for developers who need social distribution and brand updates without leaving the coding environment.
About
canghe-post-to-x is a Canghe agent skill for composing and publishing tweets or multi-post threads to X.com directly from an agent workflow. The skill enables social distribution, product announcements, and engagement updates without switching to a separate social client. Developers reach for canghe-post-to-x after shipping features or drafting release notes they want to broadcast on X. It automates the compose-and-publish step for X.com rather than building in-app UI or SEO pages. Use it when agent-driven workflows should post finished copy to X as part of a launch or growth routine.
- X.com tweet publishing from agents
- Thread and single-post composition
- Social distribution automation
- Brand voice and engagement workflows
- Integrates with growth content pipelines
Canghe Post To X by the numbers
- 459 all-time installs (skills.sh)
- Ranked #784 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/freestylefly/canghe-skills --skill canghe-post-to-xAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 459 |
|---|---|
| repo stars | ★ 424 |
| Last updated | June 8, 2026 |
| Repository | freestylefly/canghe-skills ↗ |
How do you post tweets from an agent workflow?
Compose and publish tweets or threads to X.com from an agent workflow, enabling social distribution, brand updates, and engagement without leaving the coding environment.
Who is it for?
Developers who publish product updates and threads to X.com from agent workflows after content is ready.
Skip if: Developers who need LinkedIn scheduling, in-app social features, or long-form blog SEO instead of X.com posting.
When should I use this skill?
The user asks to compose, schedule, or publish a tweet or thread to X.com from an agent session.
What you get
Published X.com tweets or threads and drafted social copy ready for distribution
- published X.com posts
- thread copy
Files
Post to X (Twitter)
Posts text, images, videos, and long-form articles to X via real Chrome browser (bypasses anti-bot detection).
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 SKILL_DIR 2. Script path = ${SKILL_DIR}/scripts/<script-name>.ts 3. Replace all ${SKILL_DIR} in this document with the actual path
Script Reference:
| Script | Purpose |
|---|---|
scripts/x-browser.ts | Regular posts (text + images) |
scripts/x-video.ts | Video posts (text + video) |
scripts/x-quote.ts | Quote tweet with comment |
scripts/x-article.ts | Long-form article publishing (Markdown) |
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 |
Preferences (EXTEND.md)
Use Bash to check EXTEND.md existence (priority order):
# Check project-level first
test -f .canghe-skills/canghe-post-to-x/EXTEND.md && echo "project"
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
test -f "$HOME/.canghe-skills/canghe-post-to-x/EXTEND.md" && echo "user"┌──────────────────────────────────────────────────┬───────────────────┐ │ Path │ Location │ ├──────────────────────────────────────────────────┼───────────────────┤ │ .canghe-skills/canghe-post-to-x/EXTEND.md │ Project directory │ ├──────────────────────────────────────────────────┼───────────────────┤ │ $HOME/.canghe-skills/canghe-post-to-x/EXTEND.md │ User home │ └──────────────────────────────────────────────────┴───────────────────┘
┌───────────┬───────────────────────────────────────────────────────────────────────────┐ │ Result │ Action │ ├───────────┼───────────────────────────────────────────────────────────────────────────┤ │ Found │ Read, parse, apply settings │ ├───────────┼───────────────────────────────────────────────────────────────────────────┤ │ Not found │ Use defaults │ └───────────┴───────────────────────────────────────────────────────────────────────────┘
EXTEND.md Supports: Default Chrome profile
Prerequisites
- Google Chrome or Chromium
bunruntime- 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.
npx -y bun ${SKILL_DIR}/scripts/check-paste-permissions.tsChecks: 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 | Ensure ~/.local/share/x-browser-profile is writable |
| Bun runtime | `curl -fsSL https://bun.sh/install \ |
| 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.mdfor manual workflow, troubleshooting, and technical details - X Articles: See
references/articles.mdfor long-form article publishing guide
---
Regular Posts
Text + up to 4 images.
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.pngParameters:
| 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.
---
Video Posts
Text + video file.
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Check this out!" --video ./clip.mp4Parameters:
| 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.
Limits: Regular 140s max, Premium 60min. Processing: 30-60s.
---
Quote Tweets
Quote an existing tweet with comment.
npx -y bun ${SKILL_DIR}/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.
---
X Articles
Long-form Markdown articles (requires X Premium).
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpgParameters:
| Parameter | Description |
|---|---|
<markdown> | Markdown file (positional) |
--cover <path> | Cover image |
--title <text> | Override title |
Frontmatter: title, cover_image supported in YAML front matter.
Note: Script opens browser with article filled in. User reviews and publishes manually.
---
Troubleshooting
Chrome debug port not ready
If a script fails with Chrome debug port not ready or Unable to connect, kill existing Chrome CDP instances first, then retry:
pkill -f "Chrome.*remote-debugging-port" 2>/dev/null; pkill -f "Chromium.*remote-debugging-port" 2>/dev/null; sleep 2Important: 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)
- All scripts only fill content into the browser, user must review and publish manually
- Cross-platform: macOS, Linux, Windows
Extension Support
Custom configurations via EXTEND.md. See Preferences section for paths and supported options.
X Articles - Detailed Guide
Publish Markdown articles to X Articles editor with rich text formatting and images.
Prerequisites
- X Premium subscription (required for Articles)
- Google Chrome installed
buninstalled
Usage
# Publish markdown article (preview mode)
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md
# With custom cover image
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpg
# Actually publish
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submitMarkdown Format
---
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:
# Get JSON with all metadata
npx -y bun ${SKILL_DIR}/scripts/md-to-html.ts article.md
# Output HTML only
npx -y bun ${SKILL_DIR}/scripts/md-to-html.ts article.md --html-only
# Save HTML to file
npx -y bun ${SKILL_DIR}/scripts/md-to-html.ts article.md --save-html /tmp/article.htmlJSON output:
{
"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 |
Workflow
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 (reverse order):
- Find placeholder text in editor
- Select the placeholder
- Copy image to clipboard
- Paste to replace selection
9. Review: Browser stays open for 60s preview 10. Publish: Only with --submit flag
Example Session
User: /post-to-x article ./blog/my-post.md --cover ./thumbnail.png
Claude:
1. Parses markdown: title="My Post", 3 content images
2. Launches Chrome with CDP
3. Navigates to x.com/compose/articles
4. Clicks create button
5. Uploads thumbnail.png as cover
6. Fills title "My Post"
7. Pastes HTML content
8. Inserts 3 images at placeholder positions
9. Reports: "Article composed. Review and use --submit to publish."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
- Content not pasting: Check HTML clipboard:
npx -y bun ${SKILL_DIR}/scripts/copy-to-clipboard.ts html --file /tmp/test.html
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. x-article.ts publishes via CDP:
- 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
Regular Posts - Detailed Guide
Detailed documentation for posting text and images to X.
Manual Workflow
If you prefer step-by-step control:
Step 1: Copy Image to Clipboard
npx -y bun ${SKILL_DIR}/scripts/copy-to-clipboard.ts image /path/to/image.pngStep 2: Paste from Clipboard
# Simple paste to frontmost app
npx -y bun ${SKILL_DIR}/scripts/paste-from-clipboard.ts
# Paste to Chrome with retries
npx -y bun ${SKILL_DIR}/scripts/paste-from-clipboard.ts --app "Google Chrome" --retries 5
# Quick paste with shorter delay
npx -y bun ${SKILL_DIR}/scripts/paste-from-clipboard.ts --delay 200Step 3: Use Playwright MCP (if Chrome session available)
# Navigate
mcp__playwright__browser_navigate url="https://x.com/compose/post"
# Get element refs
mcp__playwright__browser_snapshot
# Type text
mcp__playwright__browser_click element="editor" ref="<ref>"
mcp__playwright__browser_type element="editor" ref="<ref>" text="Your content"
# Paste image (after copying to clipboard)
mcp__playwright__browser_press_key key="Meta+v" # macOS
# or
mcp__playwright__browser_press_key key="Control+v" # Windows/Linux
# Screenshot to verify
mcp__playwright__browser_take_screenshot filename="preview.png"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. Runs: npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello from Claude!" --image ./screenshot.png
2. Chrome opens with X compose page
3. Text is typed into editor
4. Image is copied to clipboard and pasted
5. Browser stays open 30s for preview
6. Reports: "Post composed. Use --submit to post."Troubleshooting
- Chrome not found: Set
X_BROWSER_CHROME_PATHenvironment variable - Not logged in: First run opens Chrome - log in manually, cookies are saved
- Image paste fails:
- Verify clipboard script:
npx -y bun ${SKILL_DIR}/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
The x-browser.ts script 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.
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "canghe-post-to-x-scripts",
"dependencies": {
"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.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@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=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"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=="],
"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=="],
"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.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"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=="],
"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.2", "", { "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-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
"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=="],
"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=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"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=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"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=="],
"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=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
}
}
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: curl -fsSL https://bun.sh/install | bash');
}
}
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('=== canghe-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);
});
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);
});
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import https from 'node:https';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { createHash } from 'node:crypto';
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';
interface ImageInfo {
placeholder: string;
localPath: string;
originalPath: string;
blockIndex: number;
}
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 downloadFile(url: string, destPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http;
const file = fs.createWriteStream(destPath);
const request = protocol.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location;
if (redirectUrl) {
file.close();
fs.unlinkSync(destPath);
downloadFile(redirectUrl, destPath).then(resolve).catch(reject);
return;
}
}
if (response.statusCode !== 200) {
file.close();
fs.unlinkSync(destPath);
reject(new Error(`Failed to download: ${response.statusCode}`));
return;
}
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
});
request.on('error', (err) => {
file.close();
fs.unlink(destPath, () => {});
reject(err);
});
request.setTimeout(30000, () => {
request.destroy();
reject(new Error('Download timeout'));
});
});
}
function getImageExtension(urlOrPath: string): string {
const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
return match ? match[1]!.toLowerCase() : 'png';
}
async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): Promise<string> {
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
const hash = createHash('md5').update(imagePath).digest('hex').slice(0, 8);
const ext = getImageExtension(imagePath);
const localPath = path.join(tempDir, `remote_${hash}.${ext}`);
if (!fs.existsSync(localPath)) {
console.error(`[md-to-html] Downloading: ${imagePath}`);
await downloadFile(imagePath, localPath);
}
return localPath;
}
if (path.isAbsolute(imagePath)) {
return imagePath;
}
return path.resolve(baseDir, imagePath);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.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);
}
}
function preprocessCjkMarkdown(markdown: string): string {
try {
const processor = unified()
.use(remarkParse)
.use(remarkCjkFriendly)
.use(remarkStringify);
const result = String(processor.processSync(markdown));
return result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)));
} catch {
return markdown;
}
}
function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => 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 imageCallback(href, 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 images: Array<{ src: string; alt: string; blockIndex: number }> = [];
let imageCounter = 0;
const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {
const placeholder = `XIMGPH_${++imageCounter}`;
images.push({ src, alt, blockIndex: -1 });
return placeholder;
});
const htmlLines = html.split('\n');
for (let i = 0; i < images.length; i++) {
const placeholder = `XIMGPH_${i + 1}`;
for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {
const regex = new RegExp(`\\b${placeholder}\\b`);
if (regex.test(htmlLines[lineIndex]!)) {
images[i]!.blockIndex = 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.src, baseDir, tempDir);
if (i === 0 && !coverImagePath) {
firstImageAsCover = localPath;
}
contentImages.push({
placeholder: `XIMGPH_${i + 1}`,
localPath,
originalPath: img.src,
blockIndex: img.blockIndex,
});
}
const finalHtml = html.replace(/\n{3,}/g, '\n\n').trim();
let resolvedCoverImage: string | null = null;
if (coverImagePath) {
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
} 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));
}
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
{
"name": "canghe-post-to-x-scripts",
"private": true,
"type": "module",
"dependencies": {
"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"
}
}
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();
import { spawn } from 'node:child_process';
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 { parseMarkdown } from './md-to-html.js';
import {
CHROME_CANDIDATES_BASIC,
CdpConnection,
copyHtmlToClipboard,
copyImageToClipboard,
findChromeExecutable,
getDefaultProfileDir,
getFreePort,
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;
}
async function findExistingDebugPort(profileDir: string): Promise<number | null> {
const portFile = path.join(profileDir, 'DevToolsActivePort');
if (!fs.existsSync(portFile)) return null;
try {
const content = fs.readFileSync(portFile, 'utf-8').trim();
if (!content) return null;
const [portLine] = content.split(/\r?\n/);
const port = Number(portLine);
if (!Number.isFinite(port) || port <= 0) return null;
// Verify the port is actually active.
await waitForChromeDebugPort(port, 1500, { includeLastError: true });
return port;
} catch {
return null;
}
}
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}`);
const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_BASIC);
if (!chromePath) throw new Error('Chrome not found');
await mkdir(profileDir, { recursive: true });
const existingPort = await findExistingDebugPort(profileDir);
const port = existingPort ?? await getFreePort();
if (existingPort) {
console.log(`[x-article] Reusing existing Chrome instance on port ${port}`);
} else {
console.log(`[x-article] Launching Chrome...`);
const chromeArgs = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'--start-maximized',
X_ARTICLES_URL,
];
if (process.platform === 'darwin') {
const appPath = chromePath.replace(/\/Contents\/MacOS\/Google Chrome$/, '');
spawn('open', ['-na', appPath, '--args', ...chromeArgs], { stdio: 'ignore' });
} else {
spawn(chromePath, chromeArgs, { stdio: 'ignore' });
}
}
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 30_000 });
// Get page target
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.startsWith(X_ARTICLES_URL));
if (!pageTarget) {
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_ARTICLES_URL });
pageTarget = { targetId, url: X_ARTICLES_URL, type: 'page' };
}
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
await cdp.send('Page.enable', {}, { sessionId });
await cdp.send('Runtime.enable', {}, { sessionId });
await cdp.send('DOM.enable', {}, { sessionId });
console.log('[x-article] Waiting for articles page...');
await sleep(3000);
// 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"]', 10_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);
}
// 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))`);
} 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.');
}
// 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);
});
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { mkdir } from 'node:fs/promises';
import process from 'node:process';
import {
CHROME_CANDIDATES_FULL,
CdpConnection,
copyImageToClipboard,
findChromeExecutable,
getDefaultProfileDir,
getFreePort,
pasteFromClipboard,
sleep,
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;
const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL);
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
await mkdir(profileDir, { recursive: true });
const port = await getFreePort();
console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`);
const chrome = spawn(chromePath, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'--start-maximized',
X_COMPOSE_URL,
], { stdio: 'ignore' });
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_000 });
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
if (!pageTarget) {
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_COMPOSE_URL });
pageTarget = { targetId, url: X_COMPOSE_URL, type: 'page' };
}
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
await cdp.send('Page.enable', {}, { sessionId });
await cdp.send('Runtime.enable', {}, { sessionId });
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
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 });
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.');
}
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 });
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 });
// 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 });
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 });
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
}
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 });
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 });
await sleep(2000);
console.log('[x-browser] Post submitted!');
} else {
console.log('[x-browser] Post composed (preview mode). Add --submit to post.');
console.log('[x-browser] Browser will stay open for 30 seconds for preview...');
await sleep(30_000);
}
} finally {
if (cdp) {
try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {}
cdp.close();
}
setTimeout(() => {
if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {}
}, 2_000).unref?.();
try { chrome.kill('SIGTERM'); } catch {}
}
}
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);
});
import { spawn } from 'node:child_process';
import { mkdir } from 'node:fs/promises';
import process from 'node:process';
import {
CHROME_CANDIDATES_FULL,
CdpConnection,
findChromeExecutable,
getDefaultProfileDir,
getFreePort,
sleep,
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;
const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL);
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
await mkdir(profileDir, { recursive: true });
const port = await getFreePort();
console.log(`[x-quote] Launching Chrome (profile: ${profileDir})`);
console.log(`[x-quote] Opening tweet: ${tweetUrl}`);
const chrome = spawn(chromePath, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'--start-maximized',
tweetUrl,
], { stdio: 'ignore' });
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_000 });
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
if (!pageTarget) {
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: tweetUrl });
pageTarget = { targetId, url: tweetUrl, type: 'page' };
}
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
await cdp.send('Page.enable', {}, { sessionId });
await cdp.send('Runtime.enable', {}, { sessionId });
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 });
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.');
}
// Click the retweet button
console.log('[x-quote] Clicking retweet button...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="retweet"]')?.click()`,
}, { sessionId });
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 });
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 });
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 });
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 });
await sleep(200);
await cdp.send('Input.insertText', {
text: comment,
}, { sessionId });
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 });
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 {
if (cdp) {
try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {}
cdp.close();
}
setTimeout(() => {
if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {}
}, 2_000).unref?.();
try { chrome.kill('SIGTERM'); } catch {}
}
}
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);
});
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
export type PlatformCandidates = {
darwin?: string[];
win32?: string[];
default: string[];
};
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',
],
};
function getCandidatesForPlatform(candidates: PlatformCandidates): string[] {
if (process.platform === 'darwin' && candidates.darwin?.length) return candidates.darwin;
if (process.platform === 'win32' && candidates.win32?.length) return candidates.win32;
return candidates.default;
}
export function findChromeExecutable(candidates: PlatformCandidates): string | undefined {
const override = process.env.X_BROWSER_CHROME_PATH?.trim();
if (override && fs.existsSync(override)) return override;
for (const candidate of getCandidatesForPlatform(candidates)) {
if (fs.existsSync(candidate)) return candidate;
}
return undefined;
}
export function getDefaultProfileDir(): string {
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
return path.join(base, 'x-browser-profile');
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Unable to allocate a free TCP port.')));
return;
}
const port = address.port;
server.close((err) => {
if (err) reject(err);
else resolve(port);
});
});
});
}
async function fetchJson<T = unknown>(url: string): Promise<T> {
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
return (await res.json()) as T;
}
export async function waitForChromeDebugPort(
port: number,
timeoutMs: number,
options?: { includeLastError?: boolean },
): Promise<string> {
const start = Date.now();
let lastError: unknown = null;
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
lastError = new Error('Missing webSocketDebuggerUrl');
} catch (error) {
lastError = error;
}
await sleep(200);
}
if (options?.includeLastError && lastError) {
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
throw new Error('Chrome debug port not ready');
}
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout> | null;
};
export class CdpConnection {
private ws: WebSocket;
private nextId = 0;
private pending = new Map<number, PendingRequest>();
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
private defaultTimeoutMs: number;
private constructor(ws: WebSocket, options?: { defaultTimeoutMs?: number }) {
this.ws = ws;
this.defaultTimeoutMs = options?.defaultTimeoutMs ?? 15_000;
this.ws.addEventListener('message', (event) => {
try {
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } };
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) handlers.forEach((h) => h(msg.params));
}
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer) clearTimeout(pending.timer);
if (msg.error?.message) pending.reject(new Error(msg.error.message));
else pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener('close', () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer) clearTimeout(pending.timer);
pending.reject(new Error('CDP connection closed.'));
}
});
}
static async connect(url: string, timeoutMs: number, options?: { defaultTimeoutMs?: number }): Promise<CdpConnection> {
const ws = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); });
});
return new CdpConnection(ws, options);
}
on(method: string, handler: (params: unknown) => void): void {
if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set());
this.eventHandlers.get(method)!.add(handler);
}
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
const id = ++this.nextId;
const message: Record<string, unknown> = { id, method };
if (params) message.params = params;
if (options?.sessionId) message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
const result = await new Promise<unknown>((resolve, reject) => {
const timer = timeoutMs > 0
? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs)
: null;
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(message));
});
return result as T;
}
close(): void {
try { this.ws.close(); } catch {}
}
}
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);
}
Related skills
How it compares
Pick canghe-post-to-x over SEO skills when the channel is X.com social posting, not on-page metadata or search ranking work.
FAQ
What does canghe-post-to-x publish?
canghe-post-to-x composes and publishes tweets or multi-post threads to X.com from agent workflows, supporting product announcements, brand updates, and social distribution without a separate client.
When should developers use canghe-post-to-x?
Developers should use canghe-post-to-x when release copy or updates are ready and need posting to X.com as part of an agent-driven launch or growth workflow.