#!/usr/bin/env node
/**
* Unified CLI for XiaoHongShu operations
*/
import { Command } from 'commander';
import { getConfig } from '../lib/shared/config.js';
import { AuthService } from '../lib/auth/auth.service.js';
import puppeteer from 'puppeteer';
import { spawnSync } from 'node:child_process';
import { XHSMCPServer, XHSHTTPMCPServer } from '../server/index.js';
import { XHS_TOOL_SCHEMAS } from '../server/schemas/tool.schemas.js';
import { FeedService } from '../lib/feeds/feed.service.js';
import { PublishService } from '../lib/publishing/publish.service.js';
async function main(): Promise<void> {
const config = getConfig();
const program = new Command();
function formatErrorMessage(error: unknown): string {
const raw = error instanceof Error ? (error.stack ?? error.message) : String(error);
const condensed = raw
.replace(/[\s\S]*?Looks like Puppeteer[\s\S]*?Please run the following command to download new browsers:[\s\S]*?npx puppeteer browsers install[\s\S]*?(Puppeteer Team[\s\S]*?)*?/m, '')
.replace(/[\u2500-\u257F]+/g, '') // box-drawing characters
.trim();
if (/Executable doesn't exist|puppeteer browsers install/i.test(raw)) {
return 'Chromium is not installed. Run: npx puppeteer browsers install chrome or xhs-cli browser';
}
return condensed || (error instanceof Error ? error.message : String(error));
}
function writeJson(output: unknown, exitCode = 0): void {
const compact = program.getOptionValue?.('compact') === true;
const json = compact ? JSON.stringify(output) : JSON.stringify(output, null, 2);
process.stdout.write(`${json}\n`);
process.exit(exitCode);
}
function printSuccess(result: any, message?: string): void {
const payload = {
success: true,
message: message ?? result?.message ?? undefined,
data: result,
};
writeJson(payload, 0);
}
function printError(error: unknown, code?: string): void {
const message = formatErrorMessage(error);
const payload = { success: false, message, code: code ?? undefined, status: 'error' } as const;
writeJson(payload, 1);
}
program
.name('xhs-cli')
.description('XiaoHongShu CLI with subcommands')
.option('--compact', 'Output compact one-line JSON (no pretty print)')
.showHelpAfterError();
program
.command('login')
.description('Start XiaoHongShu login flow (opens browser, saves cookies)')
.option('-t, --timeout <seconds>', 'Login timeout in seconds', `${config.browser.loginTimeout}`)
.action(async (opts: { timeout?: string }) => {
const browserPath = undefined;
const timeoutSec = opts.timeout ? parseInt(opts.timeout, 10) : config.browser.loginTimeout;
const authService = new AuthService(config);
try {
const result = await authService.login(browserPath, timeoutSec);
printSuccess(result);
} catch (error) {
printError(error);
}
});
program
.command('logout')
.description('Logout from XiaoHongShu and clear saved cookies')
.action(async () => {
const authService = new AuthService(config);
try {
const result = await authService.logout();
printSuccess(result);
} catch (error) {
printError(error);
}
});
program
.command('status')
.description('Check current XiaoHongShu login status')
.action(async () => {
const browserPath = undefined;
const authService = new AuthService(config);
try {
const result = await authService.checkStatus(browserPath);
printSuccess(result);
} catch (error) {
printError(error);
}
});
program
.command('browser')
.description('Ensure Puppeteer Chromium is installed and launchable; installs if missing')
.option('--with-deps', 'Install OS-level dependencies as well (may require sudo)')
.action(async (opts: { withDeps?: boolean }) => {
async function canLaunchChromium(): Promise<{ canLaunch: boolean; executablePath?: string }> {
try {
// Get the executable path before launching
const executablePath = puppeteer.executablePath();
const browser = await puppeteer.launch({ headless: true });
await browser.close();
return { canLaunch: true, executablePath };
} catch {
return { canLaunch: false };
}
}
const browserInfo = await canLaunchChromium();
if (browserInfo.canLaunch) {
printSuccess({
installed: true,
executablePath: browserInfo.executablePath
}, 'Chromium is ready');
return;
}
const args = ['puppeteer', 'browsers', 'install', 'chrome'];
if (opts.withDeps) args.push('--with-deps');
const result = spawnSync('npx', args, { stdio: 'inherit', env: process.env });
const afterInstallInfo = await canLaunchChromium();
if (afterInstallInfo.canLaunch) {
printSuccess({
installed: true,
executablePath: afterInstallInfo.executablePath,
exitCode: result.status ?? null
}, 'Chromium installed and ready');
} else {
printError(new Error('Failed to install or launch Chromium'));
}
});
// Feeds: discover
program
.command('feeds')
.description('Discover home page feeds')
.option('-b, --browser-path <path>', 'Custom browser binary path')
.action(async (opts: { browserPath?: string }) => {
const feedService = new FeedService(config);
try {
const result = await feedService.getFeedList(opts.browserPath);
printSuccess(result);
} catch (error) {
printError(error);
}
});
// Feeds: search note
program
.command('search')
.description('Search notes by keyword')
.requiredOption('-k, --keyword <keyword>', 'Search keyword')
.option('-b, --browser-path <path>', 'Custom browser binary path')
.action(async (opts: { keyword: string; browserPath?: string }) => {
const feedService = new FeedService(config);
try {
const result = await feedService.searchFeeds(opts.keyword, opts.browserPath);
printSuccess(result);
} catch (error) {
printError(error);
}
});
// Feeds: get note detail
program
.command('note-detail')
.description('Get detailed information about a specific note')
.requiredOption('--feed-id <id>', 'Feed ID')
.requiredOption('--xsec-token <token>', 'Security token for the feed')
.option('-b, --browser-path <path>', 'Custom browser binary path')
.action(async (opts: { feedId: string; xsecToken: string; browserPath?: string }) => {
const feedService = new FeedService(config);
try {
const result = await feedService.getFeedDetail(opts.feedId, opts.xsecToken, opts.browserPath);
printSuccess(result);
} catch (error) {
printError(error);
}
});
// Feeds: comment on note
program
.command('comment')
.description('Comment on a note')
.requiredOption('--feed-id <id>', 'Feed ID')
.requiredOption('--xsec-token <token>', 'Security token for the feed')
.requiredOption('-n, --note <text>', 'Comment content')
.option('-b, --browser-path <path>', 'Custom browser binary path')
.action(async (opts: { feedId: string; xsecToken: string; note: string; browserPath?: string }) => {
const feedService = new FeedService(config);
try {
const result = await feedService.commentOnFeed(opts.feedId, opts.xsecToken, opts.note, opts.browserPath);
printSuccess(result);
} catch (error) {
printError(error);
}
});
// Publishing: unified publish command
program
.command('publish')
.description('Publish content to XiaoHongShu (supports both images and videos)')
.requiredOption('-t, --type <type>', 'Content type: "image" for images, "video" for videos')
.requiredOption('--title <title>', 'Content title (<= 20 chars)')
.requiredOption('--content <content>', 'Content description (<= 1000 chars)')
.requiredOption('-m, --media <paths>', 'Comma-separated media file paths (1-18 images for image posts, exactly 1 video for videos)')
.option('--tags <tags>', 'Comma-separated tags')
.option('-b, --browser-path <path>', 'Custom browser binary path')
.action(async (opts: { type: string; title: string; content: string; media: string; tags?: string; browserPath?: string }) => {
const publishService = new PublishService(config);
try {
if (opts.type !== 'image' && opts.type !== 'video') {
printError(new Error('Type must be "image" or "video"'));
return;
}
const mediaPaths = opts.media.split(',').map(s => s.trim()).filter(Boolean);
const result = await publishService.publishContent(
opts.type as 'image' | 'video',
opts.title,
opts.content,
mediaPaths,
opts.tags,
opts.browserPath
);
printSuccess(result);
} catch (error) {
printError(error);
}
});
program
.command('mcp')
.description('Start XHS MCP server (stdio or http mode)')
.option('-m, --mode <mode>', 'Server mode: stdio or http', 'stdio')
.option('-p, --port <port>', 'HTTP server port (only for http mode)', '3000')
.action(async (opts: { mode?: string; port?: string }) => {
try {
if (opts.mode === 'http') {
const port = opts.port ? parseInt(opts.port, 10) : 3000;
const httpServer = new XHSHTTPMCPServer(port);
await httpServer.start();
} else {
const server = new XHSMCPServer();
await server.start();
}
} catch (error) {
// Avoid polluting stdio mode; only emit minimal error info
printError(error);
}
});
// List MCP tools
program
.command('tools')
.description('List available MCP tools')
.option('-j, --json', 'Output in JSON format')
.option('-d, --detailed', 'Show detailed tool information')
.action(async (opts: { json?: boolean; detailed?: boolean }) => {
try {
const toolSchemas = XHS_TOOL_SCHEMAS;
if (opts.json) {
if (opts.detailed) {
console.log(JSON.stringify(toolSchemas, null, 2));
} else {
const toolNames = toolSchemas.map(tool => ({
name: tool.name,
description: tool.description
}));
console.log(JSON.stringify(toolNames, null, 2));
}
} else {
console.log('\n📋 Available MCP Tools:\n');
toolSchemas.forEach((tool, index) => {
console.log(`${index + 1}. ${tool.name}`);
console.log(` ${tool.description}`);
if (opts.detailed) {
const required = tool.inputSchema.required || [];
const properties = tool.inputSchema.properties || {};
if (Object.keys(properties).length > 0) {
console.log(' Parameters:');
Object.entries(properties).forEach(([key, prop]: [string, any]) => {
const requiredMark = required.includes(key) ? ' (required)' : ' (optional)';
console.log(` - ${key}: ${prop.description || 'No description'}${requiredMark}`);
});
}
}
console.log('');
});
console.log(`Total: ${toolSchemas.length} tools available`);
console.log('\nUse --detailed for parameter information');
console.log('Use --json for machine-readable output');
}
} catch (error) {
printError(error);
}
});
program.parseAsync(process.argv);
}
main();