diff --git a/skills/baoyu-post-to-x/SKILL.md b/skills/baoyu-post-to-x/SKILL.md index 07dcb81..37ffbb3 100644 --- a/skills/baoyu-post-to-x/SKILL.md +++ b/skills/baoyu-post-to-x/SKILL.md @@ -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/copy-to-clipboard.ts` | Copy content to clipboard | | `scripts/paste-from-clipboard.ts` | Send real paste keystroke | +| `scripts/check-paste-permissions.ts` | Verify environment & permissions | ## Preferences (EXTEND.md) @@ -63,6 +64,28 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user" - `bun` runtime - First run: log in to X manually (session saved) +## Pre-flight Check (Optional) + +Before first use, suggest running the environment check. User can skip if they prefer. + +```bash +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 - **Regular Posts**: See `references/regular-posts.md` for manual workflow, troubleshooting, and technical details @@ -151,6 +174,18 @@ npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit # --- +## 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 - First run: manual login required (session persists) diff --git a/skills/baoyu-post-to-x/scripts/check-paste-permissions.ts b/skills/baoyu-post-to-x/scripts/check-paste-permissions.ts new file mode 100644 index 0000000..49a3729 --- /dev/null +++ b/skills/baoyu-post-to-x/scripts/check-paste-permissions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +}); diff --git a/skills/baoyu-post-to-x/scripts/x-article.ts b/skills/baoyu-post-to-x/scripts/x-article.ts index ad9673b..d2c7215 100644 --- a/skills/baoyu-post-to-x/scripts/x-article.ts +++ b/skills/baoyu-post-to-x/scripts/x-article.ts @@ -602,6 +602,12 @@ export async function publishArticle(options: ArticleOptions): Promise { await sleep(500); } + // Count existing image blocks before paste + const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', { + expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`, + returnByValue: true, + }, { sessionId }); + // Focus editor to ensure cursor is in position await cdp.send('Runtime.evaluate', { expression: `(() => { @@ -619,9 +625,31 @@ export async function publishArticle(options: ArticleOptions): Promise { console.warn(`[x-article] Failed to paste image after retries`); } - // Wait for image to upload - console.log(`[x-article] Waiting for upload...`); - await sleep(5000); + // Verify image appeared in editor + console.log(`[x-article] Verifying image upload...`); + const expectedImgCount = imgCountBefore.result.value + 1; + let imgUploadOk = false; + const imgWaitStart = Date.now(); + while (Date.now() - imgWaitStart < 15_000) { + const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', { + expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`, + returnByValue: true, + }, { sessionId }); + if (r.result.value >= expectedImgCount) { + imgUploadOk = true; + break; + } + await sleep(1000); + } + + if (imgUploadOk) { + console.log(`[x-article] Image upload verified (${expectedImgCount} image block(s))`); + } else { + console.warn(`[x-article] Image upload not detected after 15s`); + if (i === 0) { + console.error('[x-article] First image paste failed. Run check-paste-permissions.ts to diagnose.'); + } + } } console.log('[x-article] All images processed.'); diff --git a/skills/baoyu-post-to-x/scripts/x-browser.ts b/skills/baoyu-post-to-x/scripts/x-browser.ts index 224a48c..98dcc9b 100644 --- a/skills/baoyu-post-to-x/scripts/x-browser.ts +++ b/skills/baoyu-post-to-x/scripts/x-browser.ts @@ -117,6 +117,12 @@ export async function postToX(options: XBrowserOptions): Promise { continue; } + // Count uploaded images before paste + const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', { + expression: `document.querySelectorAll('img[src^="blob:"]').length`, + returnByValue: true, + }, { sessionId }); + // Wait for clipboard to be ready await sleep(500); @@ -150,8 +156,27 @@ export async function postToX(options: XBrowserOptions): Promise { }, { sessionId }); } - console.log('[x-browser] Waiting for image upload...'); - await sleep(4000); + console.log('[x-browser] Verifying image upload...'); + const expectedImgCount = imgCountBefore.result.value + 1; + let imgUploadOk = false; + const imgWaitStart = Date.now(); + while (Date.now() - imgWaitStart < 15_000) { + const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', { + expression: `document.querySelectorAll('img[src^="blob:"]').length`, + returnByValue: true, + }, { sessionId }); + if (r.result.value >= expectedImgCount) { + imgUploadOk = true; + break; + } + await sleep(1000); + } + + if (imgUploadOk) { + console.log('[x-browser] Image upload verified'); + } else { + console.warn('[x-browser] Image upload not detected after 15s. Run check-paste-permissions.ts to diagnose.'); + } } if (submit) {