Compare commits

...

17 Commits

Author SHA1 Message Date
Jim Liu 宝玉 7b2c02a007 chore: release v1.34.1 2026-02-20 03:00:26 -06:00
Jim Liu 宝玉 98f49eae96 Merge pull request #45 from LyInfi/fix/wechat-browser-upload-progress-reeval
fix(wechat-browser): fix upload progress check crashing on second iteration
2026-02-19 18:41:51 -06:00
LyInfi 1bdf44df9e fix(wechat-browser): fix upload progress check crashing on second iteration
Runtime.evaluate reuses the same JS execution context across calls in a
session. The previous expression used `const thumbs = ...` which throws
"Identifier 'thumbs' has already been declared" on the second loop
iteration, causing result.value to be undefined and JSON.parse to throw
"JSON Parse error: Unexpected identifier 'undefined'".

Fix by inlining the querySelector into a single expression with no
variable declaration, eliminating the re-declaration error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 12:12:26 +08:00
Jim Liu 宝玉 d59ecf22c1 chore: release v1.34.0 2026-02-17 13:32:50 -06:00
Jim Liu 宝玉 22fa1a62fc refactor(baoyu-article-illustrator): enforce prompt file creation before image generation 2026-02-17 13:30:34 -06:00
Jim Liu 宝玉 3d48eed33a Merge pull request #44 from jeffrey94/feat/ref-image-chain
feat(baoyu-xhs-images): add reference image chain for visual consistency
2026-02-17 13:19:34 -06:00
Jim Liu 宝玉 8f1c4a65dd chore: release v1.33.1 2026-02-14 14:52:51 -06:00
Jim Liu 宝玉 9b97720f16 docs(baoyu-post-to-x): remove --submit flag and clarify manual publish workflow 2026-02-14 14:48:18 -06:00
Jim Liu 宝玉 145e1d2d04 refactor(baoyu-post-to-x): replace hand-rolled markdown parser with marked ecosystem
Switch md-to-html from manual line-by-line parsing to marked + front-matter +
highlight.js + remark-cjk-friendly for robust markdown conversion with syntax
highlighting, proper CJK handling, and standard frontmatter parsing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:37:14 -06:00
jeffrey94 49403b6fab feat(baoyu-xhs-images): add reference image chain for visual consistency
When generating multi-image series, use image 1 as --ref for all
subsequent images. This anchors character design, color rendering,
and illustration style across the entire series — critical for styles
with recurring characters or mascots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:49:09 +08:00
Jim Liu 宝玉 6d56f1dae8 chore: release v1.33.0 2026-02-13 20:28:11 -06:00
Jim Liu 宝玉 dd08a2aa89 feat(baoyu-post-to-wechat): add pre-flight environment check script 2026-02-13 20:27:31 -06:00
Jim Liu 宝玉 d9b47debb3 feat(baoyu-post-to-x): add pre-flight check and image upload verification 2026-02-13 20:27:27 -06:00
Jim Liu 宝玉 6cc8627ca8 chore: release v1.32.0 2026-02-12 01:58:23 -06:00
Jim Liu 宝玉 54103dfd7d refactor(baoyu-post-to-wechat): prioritize coverImage over featureImage in frontmatter lookup 2026-02-12 01:56:32 -06:00
Jim Liu 宝玉 6012b9cc88 refactor(baoyu-format-markdown): rename featureImage to coverImage as primary frontmatter key 2026-02-12 01:56:28 -06:00
Jim Liu 宝玉 260b71cdd7 feat(baoyu-danger-x-to-markdown): add --download-media flag and media localization 2026-02-12 01:56:24 -06:00
27 changed files with 1669 additions and 247 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
}, },
"metadata": { "metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency", "description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.31.2" "version": "1.34.1"
}, },
"plugins": [ "plugins": [
{ {
+37
View File
@@ -2,6 +2,43 @@
English | [中文](./CHANGELOG.zh.md) English | [中文](./CHANGELOG.zh.md)
## 1.34.1 - 2026-02-20
### Fixes
- `baoyu-post-to-wechat`: fix upload progress check crashing on second iteration (by @LyInfi)
## 1.34.0 - 2026-02-17
### Features
- `baoyu-xhs-images`: add reference image chain for visual consistency across multi-image series (by @jeffrey94)
### Refactor
- `baoyu-article-illustrator`: enforce prompt file creation as blocking step before image generation, add structured prompt quality requirements (ZONES / LABELS / COLORS / STYLE / ASPECT) and verification checklist.
## 1.33.1 - 2026-02-14
### Refactor
- `baoyu-post-to-x`: replace hand-rolled markdown parser with marked ecosystem for X Articles HTML conversion.
### Documentation
- `baoyu-post-to-x`: remove `--submit` flag from all scripts; clarify that scripts only fill content into browser for manual review and publish.
## 1.33.0 - 2026-02-13
### Features
- `baoyu-post-to-x`: add pre-flight environment check script (`check-paste-permissions.ts`); add troubleshooting section for Chrome debug port conflicts; replace fixed sleep with image upload verification polling up to 15s.
- `baoyu-post-to-wechat`: add pre-flight environment check script (`check-permissions.ts`) covering Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, API credentials.
## 1.32.0 - 2026-02-12
### Features
- `baoyu-danger-x-to-markdown`: add `--download-media` flag to download images/videos locally and rewrite markdown links to relative paths; add media localization module; add first-time setup with EXTEND.md preferences; add `coverImage` to frontmatter output.
### Refactor
- `baoyu-danger-x-to-markdown`: use camelCase for frontmatter keys (`tweetCount`, `coverImage`, `requestedUrl`, etc.).
- `baoyu-format-markdown`: rename `featureImage` to `coverImage` as primary frontmatter key (with `featureImage` as accepted alias).
- `baoyu-post-to-wechat`: prioritize `coverImage` over `featureImage` in cover image frontmatter lookup order.
## 1.31.2 - 2026-02-10 ## 1.31.2 - 2026-02-10
### Fixes ### Fixes
+37
View File
@@ -2,6 +2,43 @@
[English](./CHANGELOG.md) | 中文 [English](./CHANGELOG.md) | 中文
## 1.34.1 - 2026-02-20
### 修复
- `baoyu-post-to-wechat`:修复上传进度检查在第二次迭代时崩溃的问题 (by @LyInfi)
## 1.34.0 - 2026-02-17
### 新功能
- `baoyu-xhs-images`:新增参考图片链功能,确保多图系列的视觉一致性 (by @jeffrey94)
### 重构
- `baoyu-article-illustrator`:将提示词文件创建设为生成图片前的阻断步骤,新增结构化提示词质量要求(ZONES / LABELS / COLORS / STYLE / ASPECT)和验证清单。
## 1.33.1 - 2026-02-14
### 重构
- `baoyu-post-to-x`:将手写 markdown 解析器替换为 marked 生态系统,用于 X Articles HTML 转换。
### 文档
- `baoyu-post-to-x`:移除所有脚本的 `--submit` 参数;明确脚本仅将内容填充到浏览器,由用户手动审核和发布。
## 1.33.0 - 2026-02-13
### 新功能
- `baoyu-post-to-x`:新增环境预检脚本(`check-paste-permissions.ts`);新增 Chrome 调试端口冲突的故障排查说明;将固定等待替换为图片上传轮询验证(最长 15 秒)。
- `baoyu-post-to-wechat`:新增环境预检脚本(`check-permissions.ts`),检查 Chrome、配置文件隔离、Bun、辅助功能、剪贴板、粘贴按键和 API 凭据。
## 1.32.0 - 2026-02-12
### 新功能
- `baoyu-danger-x-to-markdown`:新增 `--download-media` 参数,支持将图片/视频下载到本地并将 markdown 链接改写为相对路径;新增媒体本地化模块;新增首次使用 EXTEND.md 偏好设置;在 frontmatter 中输出 `coverImage`
### 重构
- `baoyu-danger-x-to-markdown`frontmatter 字段改为 camelCase`tweetCount``coverImage``requestedUrl` 等)。
- `baoyu-format-markdown`:将主 frontmatter 字段从 `featureImage` 更名为 `coverImage`(兼容 `featureImage`)。
- `baoyu-post-to-wechat`:封面图片 frontmatter 查找顺序中优先使用 `coverImage`
## 1.31.2 - 2026-02-10 ## 1.31.2 - 2026-02-10
### 修复 ### 修复
+5 -2
View File
@@ -681,6 +681,9 @@ Converts X (Twitter) content to markdown format. Supports tweet threads and X Ar
# JSON output # JSON output
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json /baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
# Download media (images/videos) to local files
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --download-media
``` ```
**Supported URLs:** **Supported URLs:**
@@ -713,7 +716,7 @@ Format plain text or markdown files with proper frontmatter, titles, summaries,
**Workflow**: **Workflow**:
1. Read source file and analyze content structure 1. Read source file and analyze content structure
2. Check/create YAML frontmatter (title, slug, summary, featureImage) 2. Check/create YAML frontmatter (title, slug, summary, coverImage)
3. Handle title: use existing, extract from H1, or generate candidates 3. Handle title: use existing, extract from H1, or generate candidates
4. Apply formatting: headings, bold, lists, code blocks, quotes 4. Apply formatting: headings, bold, lists, code blocks, quotes
5. Save to `{filename}-formatted.md` 5. Save to `{filename}-formatted.md`
@@ -725,7 +728,7 @@ Format plain text or markdown files with proper frontmatter, titles, summaries,
| `title` | Use existing, extract H1, or generate candidates | | `title` | Use existing, extract H1, or generate candidates |
| `slug` | Infer from file path or generate from title | | `slug` | Infer from file path or generate from title |
| `summary` | Generate engaging summary (100-150 chars) | | `summary` | Generate engaging summary (100-150 chars) |
| `featureImage` | Check for `imgs/cover.png` in same directory | | `coverImage` | Check for `imgs/cover.png` in same directory |
**Formatting Rules**: **Formatting Rules**:
| Element | Format | | Element | Format |
+5 -2
View File
@@ -681,6 +681,9 @@ AI 驱动的生成后端。
# JSON 输出 # JSON 输出
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json /baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
# 下载媒体文件(图片/视频)到本地
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --download-media
``` ```
**支持的 URL** **支持的 URL**
@@ -713,7 +716,7 @@ AI 驱动的生成后端。
**工作流程** **工作流程**
1. 读取源文件并分析内容结构 1. 读取源文件并分析内容结构
2. 检查/创建 YAML frontmattertitle、slug、summary、featureImage 2. 检查/创建 YAML frontmattertitle、slug、summary、coverImage
3. 处理标题:使用现有标题、提取 H1 或生成候选标题 3. 处理标题:使用现有标题、提取 H1 或生成候选标题
4. 应用格式:层级标题、加粗、列表、代码块、引用 4. 应用格式:层级标题、加粗、列表、代码块、引用
5. 保存为 `{文件名}-formatted.md` 5. 保存为 `{文件名}-formatted.md`
@@ -725,7 +728,7 @@ AI 驱动的生成后端。
| `title` | 使用现有、提取 H1 或生成候选 | | `title` | 使用现有、提取 H1 或生成候选 |
| `slug` | 从文件路径推断或根据标题生成 | | `slug` | 从文件路径推断或根据标题生成 |
| `summary` | 生成吸引人的摘要(100-150 字) | | `summary` | 生成吸引人的摘要(100-150 字) |
| `featureImage` | 检查同目录下 `imgs/cover.png` | | `coverImage` | 检查同目录下 `imgs/cover.png` |
**格式化规则** **格式化规则**
| 元素 | 格式 | | 元素 | 格式 |
+10 -5
View File
@@ -100,11 +100,16 @@ Full template: [references/workflow.md](references/workflow.md#step-4-generate-o
### Step 5: Generate Images ### Step 5: Generate Images
1. Create prompts per [references/prompt-construction.md](references/prompt-construction.md) **BLOCKING: Prompt files MUST be saved before ANY image generation.**
2. Select generation skill from available skills
3. Process references (`direct`/`style`/`palette`) 1. For each illustration, create a prompt file per [references/prompt-construction.md](references/prompt-construction.md)
4. Apply watermark if EXTEND.md enabled 2. Save to `prompts/NN-{type}-{slug}.md` with YAML frontmatter
5. Generate sequentially, retry once on failure 3. Prompts **MUST** use type-specific templates with structured sections (ZONES / LABELS / COLORS / STYLE / ASPECT)
4. LABELS **MUST** include article-specific data: actual numbers, terms, metrics, quotes
5. **DO NOT** pass ad-hoc inline prompts to `--prompt` without saving prompt files first
6. Select generation skill, process references (`direct`/`style`/`palette`)
7. Apply watermark if EXTEND.md enabled
8. Generate from saved prompt files; retry once on failure
Full procedures: [references/workflow.md](references/workflow.md#step-5-generate-images) Full procedures: [references/workflow.md](references/workflow.md#step-5-generate-images)
@@ -261,10 +261,41 @@ references: # Only if references provided
## Step 5: Generate Images ## Step 5: Generate Images
### 5.1 Create Prompts ### 5.1 Create Prompts ⛔ BLOCKING
Follow [prompt-construction.md](prompt-construction.md). Save to `prompts/illustration-{slug}.md`. **Every illustration MUST have a saved prompt file before generation begins. DO NOT skip this step.**
- **Backup rule**: If prompt file exists, rename to `prompts/illustration-{slug}-backup-YYYYMMDD-HHMMSS.md`
For each illustration in the outline:
1. **Create prompt file**: `prompts/NN-{type}-{slug}.md`
2. **Include YAML frontmatter**:
```yaml
---
illustration_id: 01
type: infographic
style: custom-flat-vector
---
```
3. **Follow type-specific template** from [prompt-construction.md](prompt-construction.md)
4. **Prompt quality requirements** (all REQUIRED):
- `Layout`: Describe overall composition (grid / radial / hierarchical / left-right / top-down)
- `ZONES`: Describe each visual area with specific content, not vague descriptions
- `LABELS`: Use **actual numbers, terms, metrics, quotes from the article** — NOT generic placeholders
- `COLORS`: Specify hex codes with semantic meaning (e.g., `Coral (#E07A5F) for emphasis`)
- `STYLE`: Describe line treatment, texture, mood, character rendering
- `ASPECT`: Specify ratio (e.g., `16:9`)
5. **Apply defaults**: composition requirements, character rendering, text guidelines, watermark
6. **Backup rule**: If prompt file exists, rename to `prompts/NN-{type}-{slug}-backup-YYYYMMDD-HHMMSS.md`
**Verification** ⛔: Before proceeding to 5.2, confirm ALL prompt files exist:
```
Prompt Files:
- prompts/01-infographic-overview.md ✓
- prompts/02-infographic-distillation.md ✓
...
```
**DO NOT** pass ad-hoc inline text to `--prompt` without first saving prompt files. The generation command should either use `--promptfiles prompts/NN-{type}-{slug}.md` or read the saved file content for `--prompt`.
**CRITICAL - References in Frontmatter**: **CRITICAL - References in Frontmatter**:
- Only add `references` field if files ACTUALLY EXIST in `references/` directory - Only add `references` field if files ACTUALLY EXIST in `references/` directory
+68 -4
View File
@@ -92,16 +92,52 @@ test -f "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md" && echo "user
├───────────┼───────────────────────────────────────────────────────────────────────────┤ ├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Found │ Read, parse, apply settings │ │ Found │ Read, parse, apply settings │
├───────────┼───────────────────────────────────────────────────────────────────────────┤ ├───────────┼───────────────────────────────────────────────────────────────────────────┤
│ Not found │ Use defaults │ Not found │ **MUST** run first-time setup (see below) — do NOT silently create defaults
└───────────┴───────────────────────────────────────────────────────────────────────────┘ └───────────┴───────────────────────────────────────────────────────────────────────────┘
**EXTEND.md Supports**: Default output directory | Output format preferences **EXTEND.md Supports**: Download media by default | Default output directory
### First-Time Setup (BLOCKING)
**CRITICAL**: When EXTEND.md is not found, you **MUST use `AskUserQuestion`** to ask the user for their preferences before creating EXTEND.md. **NEVER** create EXTEND.md with defaults without asking. This is a **BLOCKING** operation — do NOT proceed with any conversion until setup is complete.
Use `AskUserQuestion` with ALL questions in ONE call:
**Question 1** — header: "Media", question: "How to handle images and videos in tweets?"
- "Ask each time (Recommended)" — After saving markdown, ask whether to download media
- "Always download" — Always download media to local imgs/ and videos/ directories
- "Never download" — Keep original remote URLs in markdown
**Question 2** — header: "Output", question: "Default output directory?"
- "x-to-markdown (Recommended)" — Save to ./x-to-markdown/{username}/{tweet-id}.md
- (User may choose "Other" to type a custom path)
**Question 3** — header: "Save", question: "Where to save preferences?"
- "User (Recommended)" — ~/.baoyu-skills/ (all projects)
- "Project" — .baoyu-skills/ (this project only)
After user answers, create EXTEND.md at the chosen location, confirm "Preferences saved to [path]", then continue.
Full reference: [references/config/first-time-setup.md](references/config/first-time-setup.md)
### Supported Keys
| Key | Default | Values | Description |
|-----|---------|--------|-------------|
| `download_media` | `ask` | `ask` / `1` / `0` | `ask` = prompt each time, `1` = always download, `0` = never |
| `default_output_dir` | empty | path or empty | Default output directory (empty = `./x-to-markdown/`) |
**Value priority**:
1. CLI arguments (`--download-media`, `-o`)
2. EXTEND.md
3. Skill defaults
## Usage ## Usage
```bash ```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> npx -y bun ${SKILL_DIR}/scripts/main.ts <url>
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> -o output.md npx -y bun ${SKILL_DIR}/scripts/main.ts <url> -o output.md
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --download-media
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
``` ```
@@ -112,6 +148,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
| `<url>` | Tweet or article URL | | `<url>` | Tweet or article URL |
| `-o <path>` | Output path | | `-o <path>` | Output path |
| `--json` | JSON output | | `--json` | JSON output |
| `--download-media` | Download image/video assets to local `imgs/` and `videos/`, and rewrite markdown links to local relative paths |
| `--login` | Refresh cookies only | | `--login` | Refresh cookies only |
## Supported URLs ## Supported URLs
@@ -124,9 +161,10 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
```markdown ```markdown
--- ---
url: https://x.com/user/status/123 url: "https://x.com/user/status/123"
author: "Name (@user)" author: "Name (@user)"
tweet_count: 3 tweetCount: 3
coverImage: "https://pbs.twimg.com/media/example.jpg"
--- ---
Content... Content...
@@ -134,6 +172,32 @@ Content...
**File structure**: `x-to-markdown/{username}/{tweet-id}.md` **File structure**: `x-to-markdown/{username}/{tweet-id}.md`
When `--download-media` is enabled:
- Images are saved to `imgs/` next to the markdown file
- Videos are saved to `videos/` next to the markdown file
- Markdown media links are rewritten to local relative paths
## Media Download Workflow
Based on `download_media` setting in EXTEND.md:
| Setting | Behavior |
|---------|----------|
| `1` (always) | Run script with `--download-media` flag |
| `0` (never) | Run script without `--download-media` flag |
| `ask` (default) | Follow the ask-each-time flow below |
### Ask-Each-Time Flow
1. Run script **without** `--download-media` → markdown saved
2. Check saved markdown for remote media URLs (`https://` in image/video links)
3. **If no remote media found** → done, no prompt needed
4. **If remote media found** → use `AskUserQuestion`:
- header: "Media", question: "Download N images/videos to local files?"
- "Yes" — Download to local directories
- "No" — Keep remote URLs
5. If user confirms → run script **again** with `--download-media` (overwrites markdown with localized links)
## Authentication ## Authentication
1. **Environment variables** (preferred): `X_AUTH_TOKEN`, `X_CT0` 1. **Environment variables** (preferred): `X_AUTH_TOKEN`, `X_CT0`
@@ -0,0 +1,106 @@
---
name: first-time-setup
description: First-time setup flow for baoyu-danger-x-to-markdown preferences
---
# First-Time Setup
## Overview
When no EXTEND.md is found, guide user through preference setup.
**BLOCKING OPERATION**: This setup MUST complete before ANY other workflow steps. Do NOT:
- Start converting tweets or articles
- Ask about URLs or output paths
- Proceed to any conversion
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
## Setup Flow
```
No EXTEND.md found
|
v
+---------------------+
| AskUserQuestion |
| (all questions) |
+---------------------+
|
v
+---------------------+
| Create EXTEND.md |
+---------------------+
|
v
Continue conversion
```
## Questions
**Language**: Use user's input language or saved language preference.
Use AskUserQuestion with ALL questions in ONE call:
### Question 1: Download Media
```yaml
header: "Media"
question: "How to handle images and videos in tweets?"
options:
- label: "Ask each time (Recommended)"
description: "After saving markdown, ask whether to download media"
- label: "Always download"
description: "Always download media to local imgs/ and videos/ directories"
- label: "Never download"
description: "Keep original remote URLs in markdown"
```
### Question 2: Default Output Directory
```yaml
header: "Output"
question: "Default output directory?"
options:
- label: "x-to-markdown (Recommended)"
description: "Save to ./x-to-markdown/{username}/{tweet-id}.md"
```
Note: User will likely choose "Other" to type a custom path.
### Question 3: Save Location
```yaml
header: "Save"
question: "Where to save preferences?"
options:
- label: "User (Recommended)"
description: "~/.baoyu-skills/ (all projects)"
- label: "Project"
description: ".baoyu-skills/ (this project only)"
```
## Save Locations
| Choice | Path | Scope |
|--------|------|-------|
| User | `~/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md` | All projects |
| Project | `.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md` | Current project |
## After Setup
1. Create directory if needed
2. Write EXTEND.md
3. Confirm: "Preferences saved to [path]"
4. Continue with conversion using saved preferences
## EXTEND.md Template
```md
download_media: [ask/1/0]
default_output_dir: [path or empty]
```
## Modifying Preferences Later
Users can edit EXTEND.md directly or delete it to trigger setup again.
@@ -6,6 +6,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { fetchXArticle } from "./graphql.js"; import { fetchXArticle } from "./graphql.js";
import { formatArticleMarkdown } from "./markdown.js"; import { formatArticleMarkdown } from "./markdown.js";
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js"; import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
import { resolveXToMarkdownConsentPath } from "./paths.js"; import { resolveXToMarkdownConsentPath } from "./paths.js";
import { tweetToMarkdown } from "./tweet-to-markdown.js"; import { tweetToMarkdown } from "./tweet-to-markdown.js";
@@ -15,6 +16,7 @@ type CliArgs = {
output: string | null; output: string | null;
json: boolean; json: boolean;
login: boolean; login: boolean;
downloadMedia: boolean;
help: boolean; help: boolean;
}; };
@@ -38,6 +40,7 @@ Usage:
Options: Options:
--output <path>, -o Output path (file or dir). Default: ./x-to-markdown/<slug>/ --output <path>, -o Output path (file or dir). Default: ./x-to-markdown/<slug>/
--json Output as JSON --json Output as JSON
--download-media Download images/videos to local ./imgs and ./videos next to markdown
--login Refresh cookies only, then exit --login Refresh cookies only, then exit
--help, -h Show help --help, -h Show help
@@ -45,6 +48,7 @@ Examples:
${cmd} https://x.com/username/status/1234567890 ${cmd} https://x.com/username/status/1234567890
${cmd} https://x.com/i/article/1234567890 -o ./article.md ${cmd} https://x.com/i/article/1234567890 -o ./article.md
${cmd} https://x.com/username/status/1234567890 -o ./out/ ${cmd} https://x.com/username/status/1234567890 -o ./out/
${cmd} https://x.com/username/status/1234567890 --download-media
${cmd} https://x.com/username/status/1234567890 --json | jq -r '.markdownPath' ${cmd} https://x.com/username/status/1234567890 --json | jq -r '.markdownPath'
${cmd} --login ${cmd} --login
`); `);
@@ -57,6 +61,7 @@ function parseArgs(argv: string[]): CliArgs {
output: null, output: null,
json: false, json: false,
login: false, login: false,
downloadMedia: false,
help: false, help: false,
}; };
@@ -80,6 +85,11 @@ function parseArgs(argv: string[]): CliArgs {
continue; continue;
} }
if (a === "--download-media") {
out.downloadMedia = true;
continue;
}
if (a === "--url") { if (a === "--url") {
const v = argv[++i]; const v = argv[++i];
if (!v) throw new Error("Missing value for --url"); if (!v) throw new Error("Missing value for --url");
@@ -345,16 +355,17 @@ async function convertArticleToMarkdown(
log(`[x-to-markdown] Fetching article ${articleId}...`); log(`[x-to-markdown] Fetching article ${articleId}...`);
const article = await fetchXArticle(articleId, cookieMap, false); const article = await fetchXArticle(articleId, cookieMap, false);
const body = formatArticleMarkdown(article).trimEnd(); const { markdown: body, coverUrl } = formatArticleMarkdown(article);
const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : ""; const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
const meta = formatMetaMarkdown({ const meta = formatMetaMarkdown({
url: `https://x.com/i/article/${articleId}`, url: `https://x.com/i/article/${articleId}`,
requested_url: inputUrl, requestedUrl: inputUrl,
title: title || null, title: title || null,
coverImage: coverUrl,
}); });
return [meta, body].filter(Boolean).join("\n\n").trimEnd(); return [meta, body.trimEnd()].filter(Boolean).join("\n\n").trimEnd();
} }
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -385,11 +396,24 @@ async function main(): Promise<void> {
const kind = articleId ? ("article" as const) : ("tweet" as const); const kind = articleId ? ("article" as const) : ("tweet" as const);
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log); const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
const markdown = let markdown =
kind === "article" && articleId kind === "article" && articleId
? await convertArticleToMarkdown(normalizedUrl, articleId, log) ? await convertArticleToMarkdown(normalizedUrl, articleId, log)
: await tweetToMarkdown(normalizedUrl, { log }); : await tweetToMarkdown(normalizedUrl, { log });
let mediaResult: LocalizeMarkdownMediaResult | null = null;
if (args.downloadMedia) {
mediaResult = await localizeMarkdownMedia(markdown, {
markdownPath,
log,
});
markdown = mediaResult.markdown;
log(
`[x-to-markdown] Media localized: images=${mediaResult.downloadedImages}, videos=${mediaResult.downloadedVideos}`
);
}
await writeFile(markdownPath, markdown, "utf8"); await writeFile(markdownPath, markdown, "utf8");
log(`[x-to-markdown] Saved: ${markdownPath}`); log(`[x-to-markdown] Saved: ${markdownPath}`);
@@ -398,11 +422,16 @@ async function main(): Promise<void> {
JSON.stringify( JSON.stringify(
{ {
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl, url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
requested_url: normalizedUrl, requestedUrl: normalizedUrl,
type: kind, type: kind,
slug, slug,
outputDir, outputDir,
markdownPath, markdownPath,
downloadMedia: args.downloadMedia,
downloadedImages: mediaResult?.downloadedImages ?? 0,
downloadedVideos: mediaResult?.downloadedVideos ?? 0,
imageDir: mediaResult?.imageDir ?? null,
videoDir: mediaResult?.videoDir ?? null,
}, },
null, null,
2 2
@@ -257,10 +257,15 @@ function renderContentBlocks(
return lines; return lines;
} }
export function formatArticleMarkdown(article: unknown): string { export type FormatArticleResult = {
markdown: string;
coverUrl: string | null;
};
export function formatArticleMarkdown(article: unknown): FormatArticleResult {
const candidate = coerceArticleEntity(article); const candidate = coerceArticleEntity(article);
if (!candidate) { if (!candidate) {
return `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``; return { markdown: `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``, coverUrl: null };
} }
const lines: string[] = []; const lines: string[] = [];
@@ -271,10 +276,8 @@ export function formatArticleMarkdown(article: unknown): string {
lines.push(`# ${title}`); lines.push(`# ${title}`);
} }
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info); const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info) ?? null;
if (coverUrl) { if (coverUrl) {
if (lines.length > 0) lines.push("");
lines.push(`![](${coverUrl})`);
usedUrls.add(coverUrl); usedUrls.add(coverUrl);
} }
@@ -294,7 +297,7 @@ export function formatArticleMarkdown(article: unknown): string {
lines.push(candidate.preview_text.trim()); lines.push(candidate.preview_text.trim());
} }
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl); const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl ?? undefined);
if (mediaUrls.length > 0) { if (mediaUrls.length > 0) {
lines.push("", "## Media", ""); lines.push("", "## Media", "");
for (const url of mediaUrls) { for (const url of mediaUrls) {
@@ -302,5 +305,5 @@ export function formatArticleMarkdown(article: unknown): string {
} }
} }
return lines.join("\n").trimEnd(); return { markdown: lines.join("\n").trimEnd(), coverUrl };
} }
@@ -0,0 +1,314 @@
import path from "node:path";
import { mkdir, writeFile } from "node:fs/promises";
type MediaKind = "image" | "video";
type MediaHint = "image" | "unknown";
type MarkdownLinkCandidate = {
url: string;
hint: MediaHint;
};
export type LocalizeMarkdownMediaOptions = {
markdownPath: string;
log?: (message: string) => void;
};
export type LocalizeMarkdownMediaResult = {
markdown: string;
downloadedImages: number;
downloadedVideos: number;
imageDir: string | null;
videoDir: string | null;
};
const MARKDOWN_LINK_RE = /(!?\[[^\]\n]*\])\((<)?(https?:\/\/[^)\s>]+)(>)?\)/g;
const IMAGE_EXTENSIONS = new Set([
"jpg",
"jpeg",
"png",
"webp",
"gif",
"bmp",
"avif",
"heic",
"heif",
"svg",
]);
const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm", "mkv"]);
const MIME_EXTENSION_MAP: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"image/bmp": "bmp",
"image/avif": "avif",
"image/heic": "heic",
"image/heif": "heif",
"image/svg+xml": "svg",
"video/mp4": "mp4",
"video/webm": "webm",
"video/quicktime": "mov",
"video/x-m4v": "m4v",
};
const DOWNLOAD_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
function normalizeContentType(raw: string | null): string {
return raw?.split(";")[0]?.trim().toLowerCase() ?? "";
}
function normalizeExtension(raw: string | undefined | null): string | undefined {
if (!raw) return undefined;
const trimmed = raw.replace(/^\./, "").trim().toLowerCase();
if (!trimmed) return undefined;
if (trimmed === "jpeg") return "jpg";
if (trimmed === "jpg") return "jpg";
return trimmed;
}
function resolveExtensionFromUrl(rawUrl: string): string | undefined {
try {
const parsed = new URL(rawUrl);
const extFromPath = normalizeExtension(path.posix.extname(parsed.pathname));
if (extFromPath) return extFromPath;
const extFromFormat = normalizeExtension(parsed.searchParams.get("format"));
if (extFromFormat) return extFromFormat;
} catch {
return undefined;
}
return undefined;
}
function resolveKindFromContentType(contentType: string): MediaKind | undefined {
if (!contentType) return undefined;
if (contentType.startsWith("image/")) return "image";
if (contentType.startsWith("video/")) return "video";
return undefined;
}
function resolveKindFromExtension(ext: string | undefined): MediaKind | undefined {
if (!ext) return undefined;
if (IMAGE_EXTENSIONS.has(ext)) return "image";
if (VIDEO_EXTENSIONS.has(ext)) return "video";
return undefined;
}
function resolveKindFromHostname(rawUrl: string): MediaKind | undefined {
try {
const hostname = new URL(rawUrl).hostname.toLowerCase();
if (hostname.includes("video.twimg.com")) return "video";
if (hostname.includes("pbs.twimg.com")) return "image";
} catch {
return undefined;
}
return undefined;
}
function resolveMediaKind(
rawUrl: string,
contentType: string,
extension: string | undefined,
hint: MediaHint
): MediaKind | undefined {
const kindFromType = resolveKindFromContentType(contentType);
if (kindFromType) return kindFromType;
const kindFromExtension = resolveKindFromExtension(extension);
if (kindFromExtension) return kindFromExtension;
const kindFromHost = resolveKindFromHostname(rawUrl);
if (kindFromHost) return kindFromHost;
if (contentType && contentType !== "application/octet-stream") {
return undefined;
}
return hint === "image" ? "image" : undefined;
}
function resolveOutputExtension(
contentType: string,
extension: string | undefined,
kind: MediaKind
): string {
const extFromMime = normalizeExtension(MIME_EXTENSION_MAP[contentType]);
if (extFromMime) return extFromMime;
const normalizedExt = normalizeExtension(extension);
if (normalizedExt) return normalizedExt;
return kind === "video" ? "mp4" : "jpg";
}
function safeDecodeURIComponent(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function sanitizeFileSegment(input: string): string {
return input
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "")
.slice(0, 48);
}
function resolveFileStem(rawUrl: string, extension: string): string {
try {
const parsed = new URL(rawUrl);
const base = path.posix.basename(parsed.pathname);
if (!base) return "";
const decodedBase = safeDecodeURIComponent(base);
const normalizedExt = normalizeExtension(extension);
const stripExt = normalizedExt ? new RegExp(`\\.${normalizedExt}$`, "i") : null;
const rawStem = stripExt ? decodedBase.replace(stripExt, "") : decodedBase;
return sanitizeFileSegment(rawStem);
} catch {
return "";
}
}
function buildFileName(kind: MediaKind, index: number, sourceUrl: string, extension: string): string {
const stem = resolveFileStem(sourceUrl, extension);
const prefix = kind === "image" ? "img" : "video";
const serial = String(index).padStart(3, "0");
const suffix = stem ? `-${stem}` : "";
return `${prefix}-${serial}${suffix}.${extension}`;
}
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")(https?:\/\/[^"]+)(")/m;
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
MARKDOWN_LINK_RE.lastIndex = 0;
const candidates: MarkdownLinkCandidate[] = [];
const seen = new Set<string>();
let match: RegExpExecArray | null;
while ((match = MARKDOWN_LINK_RE.exec(markdown))) {
const label = match[1] ?? "";
const rawUrl = match[3] ?? "";
if (!rawUrl || seen.has(rawUrl)) continue;
seen.add(rawUrl);
candidates.push({
url: rawUrl,
hint: label.startsWith("![") ? "image" : "unknown",
});
}
const fmMatch = markdown.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) {
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
seen.add(coverMatch[2]);
candidates.push({ url: coverMatch[2], hint: "image" });
}
}
return candidates;
}
function rewriteMarkdownMediaLinks(markdown: string, replacements: Map<string, string>): string {
if (replacements.size === 0) return markdown;
MARKDOWN_LINK_RE.lastIndex = 0;
let result = markdown.replace(MARKDOWN_LINK_RE, (full, label, _openAngle, rawUrl) => {
const localPath = replacements.get(rawUrl);
if (!localPath) return full;
return `${label}(${localPath})`;
});
result = result.replace(FRONTMATTER_COVER_RE, (full, prefix, rawUrl, suffix) => {
const localPath = replacements.get(rawUrl);
if (!localPath) return full;
return `${prefix}${localPath}${suffix}`;
});
return result;
}
export async function localizeMarkdownMedia(
markdown: string,
options: LocalizeMarkdownMediaOptions
): Promise<LocalizeMarkdownMediaResult> {
const log = options.log ?? (() => {});
const markdownDir = path.dirname(options.markdownPath);
const candidates = collectMarkdownLinkCandidates(markdown);
if (candidates.length === 0) {
return {
markdown,
downloadedImages: 0,
downloadedVideos: 0,
imageDir: null,
videoDir: null,
};
}
const replacements = new Map<string, string>();
let downloadedImages = 0;
let downloadedVideos = 0;
for (const candidate of candidates) {
try {
const response = await fetch(candidate.url, {
method: "GET",
redirect: "follow",
headers: {
"user-agent": DOWNLOAD_USER_AGENT,
},
});
if (!response.ok) {
log(`[x-to-markdown] Skip media (${response.status}): ${candidate.url}`);
continue;
}
const sourceUrl = response.url || candidate.url;
const contentType = normalizeContentType(response.headers.get("content-type"));
const extension = resolveExtensionFromUrl(sourceUrl) ?? resolveExtensionFromUrl(candidate.url);
const kind = resolveMediaKind(sourceUrl, contentType, extension, candidate.hint);
if (!kind) {
continue;
}
const outputExtension = resolveOutputExtension(contentType, extension, kind);
const nextIndex = kind === "image" ? downloadedImages + 1 : downloadedVideos + 1;
const dirName = kind === "image" ? "imgs" : "videos";
const targetDir = path.join(markdownDir, dirName);
await mkdir(targetDir, { recursive: true });
const fileName = buildFileName(kind, nextIndex, sourceUrl, outputExtension);
const absolutePath = path.join(targetDir, fileName);
const relativePath = path.posix.join(dirName, fileName);
const bytes = Buffer.from(await response.arrayBuffer());
await writeFile(absolutePath, bytes);
replacements.set(candidate.url, relativePath);
if (kind === "image") {
downloadedImages = nextIndex;
} else {
downloadedVideos = nextIndex;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error ?? "");
log(`[x-to-markdown] Failed to download media ${candidate.url}: ${message}`);
}
}
return {
markdown: rewriteMarkdownMediaLinks(markdown, replacements),
downloadedImages,
downloadedVideos,
imageDir: downloadedImages > 0 ? path.join(markdownDir, "imgs") : null,
videoDir: downloadedVideos > 0 ? path.join(markdownDir, "videos") : null,
};
}
@@ -123,22 +123,15 @@ export async function tweetToMarkdown(
const requestedUrl = normalizedUrl || buildTweetUrl(username, tweetId) || inputUrl.trim(); const requestedUrl = normalizedUrl || buildTweetUrl(username, tweetId) || inputUrl.trim();
const rootUrl = buildTweetUrl(username, thread.rootId ?? tweetId) ?? requestedUrl; const rootUrl = buildTweetUrl(username, thread.rootId ?? tweetId) ?? requestedUrl;
const meta = formatMetaMarkdown({
url: rootUrl,
requested_url: requestedUrl,
author,
author_name: name ?? null,
author_username: username ?? null,
author_url: authorUrl ?? null,
tweet_count: thread.totalTweets ?? tweets.length,
});
const parts: string[] = [meta];
const articleEntity = await resolveArticleEntityFromTweet(firstTweet, cookieMap); const articleEntity = await resolveArticleEntityFromTweet(firstTweet, cookieMap);
let coverImage: string | null = null;
let remainingTweets = tweets; let remainingTweets = tweets;
const parts: string[] = [];
if (articleEntity) { if (articleEntity) {
const articleMarkdown = formatArticleMarkdown(articleEntity).trimEnd(); const articleResult = formatArticleMarkdown(articleEntity);
coverImage = articleResult.coverUrl;
const articleMarkdown = articleResult.markdown.trimEnd();
if (articleMarkdown) { if (articleMarkdown) {
parts.push(articleMarkdown); parts.push(articleMarkdown);
const firstTweetText = extractTweetText(firstTweet); const firstTweetText = extractTweetText(firstTweet);
@@ -148,6 +141,19 @@ export async function tweetToMarkdown(
} }
} }
const meta = formatMetaMarkdown({
url: rootUrl,
requestedUrl: requestedUrl,
author,
authorName: name ?? null,
authorUsername: username ?? null,
authorUrl: authorUrl ?? null,
tweetCount: thread.totalTweets ?? tweets.length,
coverImage,
});
parts.unshift(meta);
if (remainingTweets.length > 0) { if (remainingTweets.length > 0) {
const hasArticle = parts.length > 1; const hasArticle = parts.length > 1;
if (hasArticle) { if (hasArticle) {
+1 -1
View File
@@ -127,7 +127,7 @@ Check for YAML frontmatter (`---` block). Create if missing.
| `title` | See Step 4 | | `title` | See Step 4 |
| `slug` | Infer from file path (e.g., `posts/2026/01/10/vibe-coding/` → `vibe-coding`) or generate from title | | `slug` | Infer from file path (e.g., `posts/2026/01/10/vibe-coding/` → `vibe-coding`) or generate from title |
| `summary` | Generate engaging summary (100-150 characters) | | `summary` | Generate engaging summary (100-150 characters) |
| `featureImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path | | `coverImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path (also accepted: `featureImage`) |
### Step 4: Title Handling ### Step 4: Title Handling
+25 -1
View File
@@ -18,6 +18,7 @@ description: Posts content to WeChat Official Account (微信公众号) via API
| `scripts/wechat-browser.ts` | Image-text posts (图文) | | `scripts/wechat-browser.ts` | Image-text posts (图文) |
| `scripts/wechat-article.ts` | Article posting via browser (文章) | | `scripts/wechat-article.ts` | Article posting via browser (文章) |
| `scripts/wechat-api.ts` | Article posting via API (文章) | | `scripts/wechat-api.ts` | Article posting via API (文章) |
| `scripts/check-permissions.ts` | Verify environment & permissions |
## Preferences (EXTEND.md) ## Preferences (EXTEND.md)
@@ -76,6 +77,29 @@ chrome_profile_path: /path/to/chrome/profile
3. EXTEND.md 3. EXTEND.md
4. Skill defaults 4. Skill defaults
## Pre-flight Check (Optional)
Before first use, suggest running the environment check. User can skip if they prefer.
```bash
npx -y bun ${SKILL_DIR}/scripts/check-permissions.ts
```
Checks: Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, API credentials, Chrome conflicts.
**If any check fails**, provide fix guidance per item:
| Check | Fix |
|-------|-----|
| Chrome | Install Chrome or set `WECHAT_BROWSER_CHROME_PATH` env var |
| Profile dir | Ensure `~/.local/share/wechat-browser-profile` is writable |
| Bun runtime | `curl -fsSL https://bun.sh/install \| bash` |
| 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) |
| API credentials | Follow guided setup in Step 5, or manually set in `.baoyu-skills/.env` |
## Image-Text Posting (图文) ## Image-Text Posting (图文)
For short posts with multiple images (up to 9): For short posts with multiple images (up to 9):
@@ -204,7 +228,7 @@ Check extracted metadata from Step 3 (or HTML meta tags if direct HTML input).
**Cover Image Check** (required for `article_type=news`): **Cover Image Check** (required for `article_type=news`):
1. Use CLI `--cover` if provided. 1. Use CLI `--cover` if provided.
2. Else use frontmatter (`featureImage`, `coverImage`, `cover`, `image`). 2. Else use frontmatter (`coverImage`, `featureImage`, `cover`, `image`).
3. Else check article directory default path: `imgs/cover.png`. 3. Else check article directory default path: `imgs/cover.png`.
4. Else fallback to first inline content image. 4. Else fallback to first inline content image.
5. If still missing, stop and request a cover image before publishing. 5. If still missing, stop and request a cover image before publishing.
@@ -0,0 +1,251 @@
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, getDefaultProfileDir } from './cdp.ts';
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();
if (chromePath) {
log('Chrome', true, chromePath);
} else {
log('Chrome', false, 'Not found. Set WECHAT_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(), 'wechat-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"
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 checkApiCredentials(): Promise<void> {
const cwd = process.cwd();
const projectEnv = path.join(cwd, '.baoyu-skills', '.env');
const userEnv = path.join(os.homedir(), '.baoyu-skills', '.env');
let found = false;
for (const envPath of [projectEnv, userEnv]) {
if (fs.existsSync(envPath)) {
const content = fs.readFileSync(envPath, 'utf8');
if (content.includes('WECHAT_APP_ID')) {
log('API credentials', true, `Found in ${envPath}`);
found = true;
break;
}
}
}
if (!found) {
warn('API credentials', 'Not found. Required for API publishing method. Run the skill to set up via guided flow.');
}
}
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.`);
} else {
log('Running Chrome instances', true, 'No existing Chrome processes');
}
}
async function main(): Promise<void> {
console.log('=== baoyu-post-to-wechat: Permission & Environment Check ===\n');
await checkChrome();
await checkProfileIsolation();
await checkBun();
await checkAccessibility();
await checkClipboardCopy();
await checkPasteKeystroke();
await checkApiCredentials();
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 WeChat.');
} 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);
});
@@ -365,7 +365,7 @@ Frontmatter Fields (markdown):
title Article title title Article title
author Author name author Author name
digest/summary Article summary digest/summary Article summary
featureImage/coverImage/cover/image Cover image path coverImage/featureImage/cover/image Cover image path
Comments: Comments:
Comments are enabled by default, open to all users. Comments are enabled by default, open to all users.
@@ -555,8 +555,8 @@ async function main(): Promise<void> {
let thumbMediaId = ""; let thumbMediaId = "";
const rawCoverPath = args.cover || const rawCoverPath = args.cover ||
frontmatter.featureImage ||
frontmatter.coverImage || frontmatter.coverImage ||
frontmatter.featureImage ||
frontmatter.cover || frontmatter.cover ||
frontmatter.image; frontmatter.image;
const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover
@@ -578,7 +578,7 @@ async function main(): Promise<void> {
} }
if (args.articleType === "news" && !thumbMediaId) { if (args.articleType === "news" && !thumbMediaId) {
console.error("Error: No cover image. Provide via --cover, frontmatter.featureImage, or include an image in content."); console.error("Error: No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
process.exit(1); process.exit(1);
} }
@@ -570,10 +570,7 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
for (let i = 0; i < 30; i++) { for (let i = 0; i < 30; i++) {
await sleep(2000); await sleep(2000);
const uploadCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { const uploadCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: ` expression: `JSON.stringify({ uploaded: document.querySelectorAll('.weui-desktop-upload__thumb, .pic_item, [class*=upload_thumb]').length })`,
const thumbs = document.querySelectorAll('.weui-desktop-upload__thumb, .pic_item, [class*=upload_thumb]');
JSON.stringify({ uploaded: thumbs.length });
`,
returnByValue: true, returnByValue: true,
}, { sessionId }); }, { sessionId });
const status = JSON.parse(uploadCheck.result.value); const status = JSON.parse(uploadCheck.result.value);
+50 -15
View File
@@ -26,6 +26,7 @@ Posts text, images, videos, and long-form articles to X via real Chrome browser
| `scripts/md-to-html.ts` | Markdown → HTML conversion | | `scripts/md-to-html.ts` | Markdown → HTML conversion |
| `scripts/copy-to-clipboard.ts` | Copy content to clipboard | | `scripts/copy-to-clipboard.ts` | Copy content to clipboard |
| `scripts/paste-from-clipboard.ts` | Send real paste keystroke | | `scripts/paste-from-clipboard.ts` | Send real paste keystroke |
| `scripts/check-paste-permissions.ts` | Verify environment & permissions |
## Preferences (EXTEND.md) ## Preferences (EXTEND.md)
@@ -55,7 +56,7 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
│ Not found │ Use defaults │ │ Not found │ Use defaults │
└───────────┴───────────────────────────────────────────────────────────────────────────┘ └───────────┴───────────────────────────────────────────────────────────────────────────┘
**EXTEND.md Supports**: Default Chrome profile | Auto-submit preference **EXTEND.md Supports**: Default Chrome profile
## Prerequisites ## Prerequisites
@@ -63,6 +64,28 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
- `bun` runtime - `bun` runtime
- First run: log in to X manually (session saved) - First run: log in to X manually (session saved)
## Pre-flight Check (Optional)
Before first use, suggest running the environment check. User can skip if they prefer.
```bash
npx -y bun ${SKILL_DIR}/scripts/check-paste-permissions.ts
```
Checks: Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, Chrome conflicts.
**If any check fails**, provide fix guidance per item:
| Check | Fix |
|-------|-----|
| Chrome | Install Chrome or set `X_BROWSER_CHROME_PATH` env var |
| Profile dir | Ensure `~/.local/share/x-browser-profile` is writable |
| Bun runtime | `curl -fsSL https://bun.sh/install \| bash` |
| 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 ## References
- **Regular Posts**: See `references/regular-posts.md` for manual workflow, troubleshooting, and technical details - **Regular Posts**: See `references/regular-posts.md` for manual workflow, troubleshooting, and technical details
@@ -75,8 +98,7 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
Text + up to 4 images. Text + up to 4 images.
```bash ```bash
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png # Preview npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --submit # Post
``` ```
**Parameters**: **Parameters**:
@@ -84,9 +106,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --subm
|-----------|-------------| |-----------|-------------|
| `<text>` | Post content (positional) | | `<text>` | Post content (positional) |
| `--image <path>` | Image file (repeatable, max 4) | | `--image <path>` | Image file (repeatable, max 4) |
| `--submit` | Post (default: preview) |
| `--profile <dir>` | Custom Chrome profile | | `--profile <dir>` | Custom Chrome profile |
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
--- ---
## Video Posts ## Video Posts
@@ -94,8 +117,7 @@ npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --subm
Text + video file. Text + video file.
```bash ```bash
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Check this out!" --video ./clip.mp4 # Preview npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Check this out!" --video ./clip.mp4
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4 --submit # Post
``` ```
**Parameters**: **Parameters**:
@@ -103,9 +125,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4
|-----------|-------------| |-----------|-------------|
| `<text>` | Post content (positional) | | `<text>` | Post content (positional) |
| `--video <path>` | Video file (MP4, MOV, WebM) | | `--video <path>` | Video file (MP4, MOV, WebM) |
| `--submit` | Post (default: preview) |
| `--profile <dir>` | Custom Chrome profile | | `--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. **Limits**: Regular 140s max, Premium 60min. Processing: 30-60s.
--- ---
@@ -115,8 +138,7 @@ npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4
Quote an existing tweet with comment. Quote an existing tweet with comment.
```bash ```bash
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "Great insight!" # Preview npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "Great insight!"
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "I agree!" --submit # Post
``` ```
**Parameters**: **Parameters**:
@@ -124,9 +146,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "I agre
|-----------|-------------| |-----------|-------------|
| `<tweet-url>` | URL to quote (positional) | | `<tweet-url>` | URL to quote (positional) |
| `<comment>` | Comment text (positional, optional) | | `<comment>` | Comment text (positional, optional) |
| `--submit` | Post (default: preview) |
| `--profile <dir>` | Custom Chrome profile | | `--profile <dir>` | Custom Chrome profile |
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
--- ---
## X Articles ## X Articles
@@ -134,9 +157,8 @@ npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "I agre
Long-form Markdown articles (requires X Premium). Long-form Markdown articles (requires X Premium).
```bash ```bash
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md # Preview npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpg # With cover npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpg
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit # Publish
``` ```
**Parameters**: **Parameters**:
@@ -145,16 +167,29 @@ npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit #
| `<markdown>` | Markdown file (positional) | | `<markdown>` | Markdown file (positional) |
| `--cover <path>` | Cover image | | `--cover <path>` | Cover image |
| `--title <text>` | Override title | | `--title <text>` | Override title |
| `--submit` | Publish (default: preview) |
**Frontmatter**: `title`, `cover_image` supported in YAML front matter. **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:
```bash
pkill -f "Chrome.*remote-debugging-port" 2>/dev/null; pkill -f "Chromium.*remote-debugging-port" 2>/dev/null; sleep 2
```
**Important**: This should be done automatically — when encountering this error, kill Chrome CDP instances and retry the command without asking the user.
## Notes ## Notes
- First run: manual login required (session persists) - First run: manual login required (session persists)
- Always preview before `--submit` - All scripts only fill content into the browser, user must review and publish manually
- Cross-platform: macOS, Linux, Windows - Cross-platform: macOS, Linux, Windows
## Extension Support ## Extension Support
+140
View File
@@ -0,0 +1,140 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "baoyu-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=="],
}
}
@@ -0,0 +1,229 @@
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('=== baoyu-post-to-x: Permission & Environment Check ===\n');
await checkChrome();
await checkProfileIsolation();
await checkBun();
await checkAccessibility();
await checkClipboardCopy();
await checkPasteKeystroke();
await checkRunningChromeConflict();
console.log('\n--- Summary ---');
const failed = results.filter((r) => !r.ok);
if (failed.length === 0) {
console.log('All checks passed. Ready to post to X.');
} else {
console.log(`${failed.length} issue(s) found:`);
for (const f of failed) {
console.log(`${f.name}: ${f.detail}`);
}
process.exit(1);
}
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+189 -173
View File
@@ -7,6 +7,14 @@ import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { createHash } from 'node:crypto'; 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 { interface ImageInfo {
placeholder: string; placeholder: string;
localPath: string; localPath: string;
@@ -22,25 +30,80 @@ interface ParsedMarkdown {
totalBlocks: number; totalBlocks: number;
} }
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } { type FrontmatterFields = Record<string, unknown>;
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content };
const frontmatter: Record<string, string> = {}; function parseFrontmatter(content: string): { frontmatter: FrontmatterFields; body: string } {
const lines = match[1]!.split('\n'); try {
for (const line of lines) { const parsed = frontMatter<FrontmatterFields>(content);
const colonIdx = line.indexOf(':'); return {
if (colonIdx > 0) { frontmatter: parsed.attributes ?? {},
const key = line.slice(0, colonIdx).trim(); body: parsed.body,
let value = line.slice(colonIdx + 1).trim(); };
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { } catch {
value = value.slice(1, -1); 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;
} }
frontmatter[key] = value;
const match = fs.readdirSync(dir).find((entry) => coverPattern.test(entry));
if (match) {
return path.join(dir, match);
}
} catch {
continue;
} }
} }
return { frontmatter, body: match[2]! }; 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> { function downloadFile(url: string, destPath: string): Promise<void> {
@@ -116,141 +179,99 @@ function escapeHtml(text: string): string {
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 } { function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {
const lines = markdown.split('\n'); const preprocessedMarkdown = preprocessCjkMarkdown(markdown);
const blocks: string[] = []; const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true });
let inCodeBlock = false;
let codeBlockContent: string[] = [];
let inList = false;
let listItems: string[] = [];
let listType: 'ul' | 'ol' = 'ul';
const flushList = () => { const renderer: RendererObject = {
if (listItems.length > 0) { heading({ depth, tokens }: Tokens.Heading): string {
const tag = listType === 'ol' ? 'ol' : 'ul'; if (depth === 1) {
blocks.push(`<${tag}>${listItems.map((item) => `<li>${item}</li>`).join('')}</${tag}>`); return '';
listItems = []; }
inList = false; 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 processInline = (text: string): string => { const parser = new Marked({
// Bold gfm: true,
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); breaks: true,
text = text.replace(/__(.+?)__/g, '<strong>$1</strong>'); });
parser.use({ renderer });
// Italic const rendered = parser.parse(preprocessedMarkdown);
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>'); if (typeof rendered !== 'string') {
text = text.replace(/_(.+?)_/g, '<em>$1</em>'); throw new Error('Unexpected async markdown parse result');
// Links
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
// Inline code
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
return text;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
// Code block
if (line.startsWith('```')) {
if (inCodeBlock) {
// X doesn't support <pre><code>, convert to blockquote
const codeContent = codeBlockContent.map((l) => escapeHtml(l)).join('<br>');
blocks.push(`<blockquote>${codeContent}</blockquote>`);
codeBlockContent = [];
inCodeBlock = false;
} else {
flushList();
inCodeBlock = true;
}
continue;
}
if (inCodeBlock) {
codeBlockContent.push(line);
continue;
}
// Empty line
if (line.trim() === '') {
flushList();
continue;
}
// Image
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
if (imgMatch) {
flushList();
const placeholder = imageCallback(imgMatch[2]!, imgMatch[1]!);
blocks.push(`<p>${placeholder}</p>`);
continue;
}
// Heading (H1 is title, skip it; H2-H6 become H2)
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
flushList();
const level = headingMatch[1]!.length;
if (level === 1) continue; // Skip H1, it's the title
blocks.push(`<h2>${processInline(headingMatch[2]!)}</h2>`);
continue;
}
// Blockquote
if (line.startsWith('> ')) {
flushList();
blocks.push(`<blockquote>${processInline(line.slice(2))}</blockquote>`);
continue;
}
// Unordered list
const ulMatch = line.match(/^[-*]\s+(.+)$/);
if (ulMatch) {
if (!inList || listType !== 'ul') {
flushList();
inList = true;
listType = 'ul';
}
listItems.push(processInline(ulMatch[1]!));
continue;
}
// Ordered list
const olMatch = line.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
if (!inList || listType !== 'ol') {
flushList();
inList = true;
listType = 'ol';
}
listItems.push(processInline(olMatch[1]!));
continue;
}
// Horizontal rule
if (/^[-*_]{3,}\s*$/.test(line)) {
flushList();
blocks.push('<hr>');
continue;
}
// Regular paragraph
flushList();
blocks.push(`<p>${processInline(line)}</p>`);
} }
flushList(); const totalBlocks = blockTokens.filter((token) => {
if (token.type === 'space') return false;
if (token.type === 'heading' && token.depth === 1) return false;
return true;
}).length;
return { return {
html: blocks.join('\n'), html: rendered,
totalBlocks: blocks.length, totalBlocks,
}; };
} }
@@ -266,59 +287,58 @@ export async function parseMarkdown(
const { frontmatter, body } = parseFrontmatter(content); const { frontmatter, body } = parseFrontmatter(content);
// Extract title from frontmatter, option, or first H1 let title = stripWrappingQuotes(options?.title ?? '') || pickFirstString(frontmatter, ['title']) || '';
let title = options?.title ?? frontmatter.title ?? '';
if (!title) { if (!title) {
const h1Match = body.match(/^#\s+(.+)$/m); title = extractTitleFromMarkdown(body);
if (h1Match) title = h1Match[1]!; }
if (!title) {
title = path.basename(markdownPath, path.extname(markdownPath));
} }
// Extract cover image from frontmatter or option let coverImagePath = stripWrappingQuotes(options?.coverImage ?? '') || pickFirstString(frontmatter, [
let coverImagePath = options?.coverImage ?? frontmatter.cover_image ?? frontmatter.coverImage ?? frontmatter.cover ?? frontmatter.image ?? frontmatter.featureImage ?? frontmatter.feature_image ?? null; 'cover_image',
'coverImage',
'cover',
'image',
'featureImage',
'feature_image',
]) || null;
if (!coverImagePath) {
coverImagePath = findCoverImageNearMarkdown(baseDir);
}
const images: Array<{ src: string; alt: string; blockIndex: number }> = []; const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
let imageCounter = 0; let imageCounter = 0;
const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => { const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {
const placeholder = `XIMGPH_${++imageCounter}`; const placeholder = `XIMGPH_${++imageCounter}`;
const currentBlockIndex = images.length; // Will be set properly after HTML generation images.push({ src, alt, blockIndex: -1 });
images.push({ src, alt, blockIndex: -1 }); // blockIndex set later
return placeholder; return placeholder;
}); });
// Update block indices by finding placeholders in HTML
const htmlLines = html.split('\n'); const htmlLines = html.split('\n');
let blockIdx = 0; for (let i = 0; i < images.length; i++) {
for (const line of htmlLines) { const placeholder = `XIMGPH_${i + 1}`;
for (let i = 0; i < images.length; i++) { for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {
const placeholder = `XIMGPH_${i + 1}`; const regex = new RegExp(`\\b${placeholder}\\b`);
if (line.includes(placeholder)) { if (regex.test(htmlLines[lineIndex]!)) {
images[i]!.blockIndex = blockIdx; images[i]!.blockIndex = lineIndex;
break;
} }
} }
blockIdx++;
} }
// Resolve image paths (download remote, resolve relative)
const contentImages: ImageInfo[] = []; const contentImages: ImageInfo[] = [];
let isFirstImage = true; let firstImageAsCover: string | null = null;
let coverPlaceholder: string | null = null;
for (let i = 0; i < images.length; i++) { for (let i = 0; i < images.length; i++) {
const img = images[i]!; const img = images[i]!;
const localPath = await resolveImagePath(img.src, baseDir, tempDir); const localPath = await resolveImagePath(img.src, baseDir, tempDir);
// First image becomes cover if no cover specified if (i === 0 && !coverImagePath) {
if (isFirstImage && !coverImagePath) { firstImageAsCover = localPath;
coverImagePath = localPath;
coverPlaceholder = `XIMGPH_${i + 1}`;
isFirstImage = false;
// Don't add to contentImages, it's the cover
continue;
} }
isFirstImage = false;
contentImages.push({ contentImages.push({
placeholder: `XIMGPH_${i + 1}`, placeholder: `XIMGPH_${i + 1}`,
localPath, localPath,
@@ -327,17 +347,13 @@ export async function parseMarkdown(
}); });
} }
// Remove cover placeholder from HTML if first image was used as cover const finalHtml = html.replace(/\n{3,}/g, '\n\n').trim();
let finalHtml = html;
if (coverPlaceholder) {
// Remove the placeholder and its containing <p> tag
finalHtml = finalHtml.replace(new RegExp(`<p>${coverPlaceholder}</p>\\n?`, 'g'), '');
}
// Resolve cover image path
let resolvedCoverImage: string | null = null; let resolvedCoverImage: string | null = null;
if (coverImagePath) { if (coverImagePath) {
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir); resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
} else if (firstImageAsCover) {
resolvedCoverImage = firstImageAsCover;
} }
return { return {
@@ -0,0 +1,14 @@
{
"name": "baoyu-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"
}
}
+31 -3
View File
@@ -602,6 +602,12 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
await sleep(500); 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 // Focus editor to ensure cursor is in position
await cdp.send('Runtime.evaluate', { await cdp.send('Runtime.evaluate', {
expression: `(() => { expression: `(() => {
@@ -619,9 +625,31 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
console.warn(`[x-article] Failed to paste image after retries`); console.warn(`[x-article] Failed to paste image after retries`);
} }
// Wait for image to upload // Verify image appeared in editor
console.log(`[x-article] Waiting for upload...`); console.log(`[x-article] Verifying image upload...`);
await sleep(5000); 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.'); console.log('[x-article] All images processed.');
+27 -2
View File
@@ -117,6 +117,12 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
continue; 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 // Wait for clipboard to be ready
await sleep(500); await sleep(500);
@@ -150,8 +156,27 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
}, { sessionId }); }, { sessionId });
} }
console.log('[x-browser] Waiting for image upload...'); console.log('[x-browser] Verifying image upload...');
await sleep(4000); 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) { if (submit) {
+13 -2
View File
@@ -368,10 +368,21 @@ Display the selected style's default elements from preset, then ask:
With confirmed outline + style + layout: With confirmed outline + style + layout:
**Visual Consistency — Reference Image Chain**:
To ensure character/style consistency across all images in a series:
1. **Generate image 1 (cover) FIRST** — without `--ref`
2. **Use image 1 as `--ref` for ALL remaining images** (2, 3, ..., N)
- This anchors the character design, color rendering, and illustration style
- Command pattern: `--ref <path-to-image-01.png>` added to every subsequent generation
This is critical for styles that use recurring characters, mascots, or illustration elements. Image 1 becomes the visual anchor for the entire series.
**For each image (cover + content + ending)**: **For each image (cover + content + ending)**:
1. Save prompt to `prompts/NN-{type}-[slug].md` (in user's preferred language) 1. Save prompt to `prompts/NN-{type}-[slug].md` (in user's preferred language)
- **Backup rule**: If prompt file exists, rename to `prompts/NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.md` - **Backup rule**: If prompt file exists, rename to `prompts/NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.md`
2. Generate image using confirmed style and layout 2. Generate image:
- **Image 1**: Generate without `--ref` (this establishes the visual anchor)
- **Images 2+**: Generate with `--ref <image-01-path>` for consistency
- **Backup rule**: If image file exists, rename to `NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.png` - **Backup rule**: If image file exists, rename to `NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.png`
3. Report progress after each generation 3. Report progress after each generation
@@ -391,7 +402,7 @@ Reference: `references/config/watermark-guide.md`
If image generation skill supports `--sessionId`: If image generation skill supports `--sessionId`:
1. Generate unique session ID: `xhs-{topic-slug}-{timestamp}` 1. Generate unique session ID: `xhs-{topic-slug}-{timestamp}`
2. Use same session ID for all images 2. Use same session ID for all images
3. Ensures visual consistency across generated images 3. Combined with reference image chain, ensures maximum visual consistency
### Step 6: Completion Report ### Step 6: Completion Report
@@ -160,7 +160,21 @@ From outline entry, format:
If preferences include watermark: If preferences include watermark:
- Add watermark section with content, position, opacity - Add watermark section with content, position, opacity
### Step 5: Combine ### Step 5: Visual Consistency — Reference Image Chain
When generating multiple images in a series:
1. **Image 1 (cover)**: Generate without `--ref` — this establishes the visual anchor
2. **Images 2+**: Always pass image 1 as `--ref` to the image generation skill:
```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts \
--promptfiles prompts/02-content-xxx.md \
--ref path/to/01-cover-xxx.png \
--image 02-content-xxx.png --ar 3:4 --quality 2k
```
This ensures the AI maintains the same character design, illustration style, and color rendering across the series.
### Step 6: Combine
Assemble all sections into final prompt following base structure. Assemble all sections into final prompt following base structure.