From 945d0758b5c62d3c7df0139f48cdb6f3d0d1308d Mon Sep 17 00:00:00 2001 From: chenmofei Date: Thu, 21 May 2026 13:48:54 +0800 Subject: [PATCH 01/16] feat(cli): add CLI tool with spinner progress and auto-save HTML - Add CLI package that converts Markdown to styled HTML via local AI agents - Support 8 coding-agent CLIs (Claude Code, Codex, Cursor Agent, Gemini, etc.) - 75 skill templates from next/src/lib/templates/skills/ - Spinner progress indicator with chunk count and elapsed time (zero deps, pure ANSI) - Auto-save output to .html when input is a file - --output-dir / -d flag to specify auto-save directory - Config management (default template, agent, model) - Stdin support for piping content Part of: nexu-io/html-anything --- cli/.gitignore | 2 + cli/README.md | 212 +++++++++++++ cli/package.json | 21 ++ cli/src/agents-detect.ts | 368 ++++++++++++++++++++++ cli/src/agents-invoke.ts | 622 +++++++++++++++++++++++++++++++++++++ cli/src/config.ts | 39 +++ cli/src/extract-html.ts | 46 +++ cli/src/index.ts | 471 ++++++++++++++++++++++++++++ cli/src/prompt-assemble.ts | 51 +++ cli/src/run.ts | 3 + cli/src/skills-loader.ts | 201 ++++++++++++ cli/tsconfig.json | 19 ++ pnpm-lock.yaml | 12 + pnpm-workspace.yaml | 1 + 14 files changed, 2068 insertions(+) create mode 100644 cli/.gitignore create mode 100644 cli/README.md create mode 100644 cli/package.json create mode 100644 cli/src/agents-detect.ts create mode 100644 cli/src/agents-invoke.ts create mode 100644 cli/src/config.ts create mode 100644 cli/src/extract-html.ts create mode 100644 cli/src/index.ts create mode 100644 cli/src/prompt-assemble.ts create mode 100644 cli/src/run.ts create mode 100644 cli/src/skills-loader.ts create mode 100644 cli/tsconfig.json diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..763301f --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ \ No newline at end of file diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..2b4ceee --- /dev/null +++ b/cli/README.md @@ -0,0 +1,212 @@ +# html-anything CLI + +从命令行直接将 Markdown(或纯文本/CSV/JSON)转换为精美排版的 HTML 文件,无需打开网页界面。 + +## 安装 + +```bash +# 1. 进入项目目录,安装依赖 +cd html-anything +pnpm install + +# 2. 构建 CLI +pnpm -F @html-anything/cli build +``` + +### 全局安装(可选) + +构建完成后,可以创建全局链接: + +```bash +# 在 cli 目录下创建全局链接 +cd cli +npm link + +# 之后可以在任意目录使用 +html-anything --help +``` + +或者将以下别名添加到 `~/.zshrc` 或 `~/.bashrc`: + +```bash +alias html-anything="node /path/to/html-anything/cli/dist/run.js" +``` + +## 快速开始 + +### 1. 设置默认模板(推荐) + +```bash +# 查看所有可用模板 +html-anything templates + +# 设置一个默认模板,之后 convert 时无需每次都指定 +html-anything config set-default-template doc-kami-parchment +``` + +### 2. 转换 Markdown 文件 + +```bash +# 使用默认模板转换(自动保存为 article.html) +html-anything convert article.md + +# 保存到指定文件 +html-anything convert article.md -o output.html + +# 指定自动保存目录 +html-anything convert article.md -d ./dist + +# 指定模板 +html-anything convert article.md -t resume-modern + +# 指定 AI agent +html-anything convert article.md -a claude --model sonnet + +# 从标准输入读取(输出到 stdout) +cat article.md | html-anything convert -o page.html +``` + +### 3. 查看生成结果 + +```bash +# 用浏览器打开生成的 HTML +open output.html +``` + +## 命令详解 + +### `convert` — 转换内容 + +```bash +html-anything convert [input] [options] +``` + +| 参数 | 简写 | 说明 | 默认值 | +|------|------|------|--------| +| `input` | — | 输入文件路径,省略则从 stdin 读取 | stdin | +| `--template ` | `-t` | 模板 ID | 配置中的 default-template | +| `--agent ` | `-a` | AI agent ID | 自动检测第一个可用 agent | +| `--output ` | `-o` | 输出文件路径 | 自动保存为 `<输入文件名>.html`,stdin 输入时输出到 stdout | +| `--output-dir ` | `-d` | 自动保存目录 | 当前目录 | +| `--model ` | — | 使用的模型 | agent 默认模型 | +| `--format ` | — | 输入格式:markdown, text, csv, json | markdown | + +### `templates` — 列出模板 + +```bash +html-anything templates +``` + +列出所有 75 个可用模板,按类别分组显示。已设为默认的模板会标记 `(default)`。 + +### `agents` — 列出 Agent + +```bash +html-anything agents +``` + +列出系统中已安装的 AI agent CLI。`✓` 表示可用,`✗` 表示未安装。 + +### `config` — 配置管理 + +```bash +html-anything config # 查看当前配置 +html-anything config set-default-template # 设置默认模板 +html-anything config set-default-agent # 设置默认 agent +html-anything config set-model # 设置默认模型 +html-anything config reset # 重置所有配置 +``` + +配置文件位于 `~/.config/html-anything/config.json`。 + +## 支持的 AI Agent + +html-anything 本身不做 AI 生成,它会自动检测并调用你系统里已安装的 AI CLI 工具(任意一个即可)来完成转换。支持的 AI CLI: + +| Agent | 安装方式 | +|-------|----------| +| Claude Code | `npm install -g @anthropic-ai/claude-code` | +| OpenAI Codex | `npm install -g @openai/codex` | +| Cursor Agent | 安装 Cursor 编辑器后可用 | +| Gemini CLI | `npm install -g @google/gemini-cli` | +| GitHub Copilot CLI | `npm install -g @github/copilot-cli` | +| OpenCode | `npm install -g @open/open-cli` | +| Qwen Coder | `npm install -g @alibaba/qwen-coder` | +| DeepSeek TUI | `npm install -g deepseek` | +| Aider | `pip install aider-chat` | +| OpenClaw | 参考官方文档安装 | + +## 常用模板推荐 + +| 模板 ID | 名称 | 适用场景 | +|---------|------|----------| +| `doc-kami-parchment` | Kami 羊皮纸文档 | 长文、报告、one-pager | +| `resume-modern` | 极简简历 | A4 单页简历 | +| `deck-swiss-international` | 瑞士国际主义 Deck | 演示文稿 | +| `deck-guizang-editorial` | 贵赞编辑墨水 Deck | 杂志风 PPT | +| `magazine-poster` | 杂志风海报 | 海报、宣传单页 | +| `blog-post` | 博客长文 | 技术博客 | +| `data-report` | 数据可视化报告 | 数据分析报告 | +| `card-xiaohongshu` | 小红书图文卡片 | 社交媒体图文 | +| `prototype-web` | Web 产品原型 | 产品原型 | +| `saas-landing` | SaaS Landing | 产品落地页 | + +## 完整使用示例 + +```bash +# 1. 首次使用:查看有哪些模板 +html-anything templates + +# 2. 设置你最喜欢的模板为默认 +html-anything config set-default-template doc-kami-parchment + +# 3. 写一篇 Markdown 文章 +cat > my-article.md << 'EOF' +# 我的项目总结 + +## 背景 +这是一个关于... + +## 成果 +- 完成了 A 功能 +- 优化了 B 模块 + +## 下一步 +我们计划在 Q3 完成... +EOF + +# 4. 一键转换(自动保存为 my-article.html) +html-anything convert my-article.md + +# 5. 在浏览器中查看结果 +open my-article.html + +# 6. 如果想换个风格 +html-anything convert my-article.md -t blog-post -o my-article-v2.html + +# 7. 保存到指定目录 +html-anything convert my-article.md -d ./output +``` + +## 开发 + +```bash +# 开发模式(无需构建,直接运行 TypeScript) +pnpm -F @html-anything/cli dev -- templates + +# 类型检查 +pnpm -F @html-anything/cli typecheck + +# 构建 +pnpm -F @html-anything/cli build +``` + +## 工作原理 + +1. **模板加载**:从 `next/src/lib/templates/skills/` 加载 75 个 SKILL 模板,每个模板包含视觉风格定义和排版规则 +2. **Prompt 拼接**:将全局设计指令 + 模板专属规则 + 用户内容拼接成一个完整的 AI prompt +3. **Agent 调用**:调用本地安装的 AI agent CLI(如 Claude Code),让 AI 根据 prompt 生成 HTML +4. **HTML 提取**:从 agent 的流式输出中提取完整的 HTML 文档 +5. **输出**:将 HTML 写入文件或打印到 stdout + +整个过程完全本地运行,不依赖任何外部 API key,使用你已有的 agent 订阅。转换过程中会显示动画进度指示器,展示已接收的文本块数和耗时。 \ No newline at end of file diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..e0950fc --- /dev/null +++ b/cli/package.json @@ -0,0 +1,21 @@ +{ + "name": "@html-anything/cli", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "bin": { + "html-anything": "./dist/run.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": {}, + "devDependencies": { + "@types/node": "^20", + "tsx": "^4.22.1", + "typescript": "^5" + } +} \ No newline at end of file diff --git a/cli/src/agents-detect.ts b/cli/src/agents-detect.ts new file mode 100644 index 0000000..1b063b6 --- /dev/null +++ b/cli/src/agents-detect.ts @@ -0,0 +1,368 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import path, { delimiter, join } from "node:path"; + +/** + * Agent detection — adapted from next/src/lib/agents/detect.ts + */ + +export type AgentProtocol = "stdin" | "argv" | "argv-message" | "acp" | "pi-rpc"; + +export type ModelOption = { id: string; label: string }; + +export const DEFAULT_MODEL: ModelOption = { id: "default", label: "Default (CLI config)" }; + +export type AgentDef = { + id: string; + label: string; + bin: string; + fallbackBins?: string[]; + envOverride?: string; + vendor: string; + protocol?: AgentProtocol; + fallbackModels: ModelOption[]; +}; + +export const AGENTS: AgentDef[] = [ + { + id: "claude", + label: "Claude Code", + bin: "claude", + fallbackBins: ["openclaude"], + envOverride: "CLAUDE_BIN", + vendor: "Anthropic", + fallbackModels: [ + DEFAULT_MODEL, + { id: "sonnet", label: "Sonnet (alias)" }, + { id: "opus", label: "Opus (alias)" }, + { id: "haiku", label: "Haiku (alias)" }, + { id: "claude-opus-4-7", label: "claude-opus-4-7" }, + { id: "claude-sonnet-4-6", label: "claude-sonnet-4-6" }, + { id: "claude-haiku-4-5", label: "claude-haiku-4-5" }, + ], + }, + { + id: "openclaw", + label: "OpenClaw", + bin: "openclaw", + envOverride: "OPENCLAW_BIN", + vendor: "OpenClaw multi-channel agent gateway", + protocol: "argv-message", + fallbackModels: [ + DEFAULT_MODEL, + { id: "openrouter/anthropic/claude-opus-4.7", label: "Opus 4.7 (OpenRouter)" }, + { id: "openrouter/anthropic/claude-sonnet-4.6", label: "Sonnet 4.6 (OpenRouter)" }, + { id: "openrouter/anthropic/claude-haiku-4.5", label: "Haiku 4.5 (OpenRouter)" }, + ], + }, + { + id: "codex", + label: "OpenAI Codex", + bin: "codex", + envOverride: "CODEX_BIN", + vendor: "OpenAI", + fallbackModels: [ + DEFAULT_MODEL, + { id: "gpt-5.5", label: "gpt-5.5" }, + { id: "gpt-5.4", label: "gpt-5.4" }, + { id: "gpt-5.4-mini", label: "gpt-5.4-mini" }, + { id: "gpt-5.3-codex", label: "gpt-5.3-codex" }, + { id: "gpt-5-codex", label: "gpt-5-codex" }, + { id: "gpt-5", label: "gpt-5" }, + { id: "o3", label: "o3" }, + { id: "o4-mini", label: "o4-mini" }, + ], + }, + { + id: "cursor-agent", + label: "Cursor Agent", + bin: "cursor-agent", + envOverride: "CURSOR_AGENT_BIN", + vendor: "Cursor", + fallbackModels: [ + DEFAULT_MODEL, + { id: "auto", label: "auto" }, + { id: "sonnet-4", label: "sonnet-4" }, + { id: "sonnet-4-thinking", label: "sonnet-4-thinking" }, + { id: "gpt-5", label: "gpt-5" }, + ], + }, + { + id: "gemini", + label: "Gemini CLI", + bin: "gemini", + envOverride: "GEMINI_BIN", + vendor: "Google", + fallbackModels: [ + DEFAULT_MODEL, + { id: "gemini-2.5-pro", label: "gemini-2.5-pro" }, + { id: "gemini-2.5-flash", label: "gemini-2.5-flash" }, + ], + }, + { + id: "copilot", + label: "GitHub Copilot CLI", + bin: "copilot", + envOverride: "COPILOT_BIN", + vendor: "GitHub", + fallbackModels: [ + DEFAULT_MODEL, + { id: "claude-sonnet-4.6", label: "Claude Sonnet 4.6" }, + { id: "gpt-5.2", label: "GPT-5.2" }, + ], + }, + { + id: "opencode", + label: "OpenCode", + bin: "opencode-cli", + fallbackBins: ["opencode"], + envOverride: "OPENCODE_BIN", + vendor: "Open", + fallbackModels: [ + DEFAULT_MODEL, + { id: "anthropic/claude-sonnet-4-5", label: "anthropic/claude-sonnet-4-5" }, + { id: "openai/gpt-5", label: "openai/gpt-5" }, + { id: "google/gemini-2.5-pro", label: "google/gemini-2.5-pro" }, + ], + }, + { + id: "qwen", + label: "Qwen Coder", + bin: "qwen", + envOverride: "QWEN_BIN", + vendor: "Alibaba", + fallbackModels: [ + DEFAULT_MODEL, + { id: "qwen3-coder-plus", label: "qwen3-coder-plus" }, + { id: "qwen3-coder-flash", label: "qwen3-coder-flash" }, + ], + }, + { + id: "qoder", + label: "Qoder CLI", + bin: "qodercli", + envOverride: "QODER_BIN", + vendor: "Qoder", + fallbackModels: [ + DEFAULT_MODEL, + { id: "lite", label: "Lite" }, + { id: "efficient", label: "Efficient" }, + { id: "auto", label: "Auto" }, + { id: "performance", label: "Performance" }, + { id: "ultimate", label: "Ultimate" }, + ], + }, + { + id: "deepseek", + label: "DeepSeek TUI", + bin: "deepseek", + envOverride: "DEEPSEEK_BIN", + vendor: "DeepSeek", + protocol: "argv", + fallbackModels: [ + DEFAULT_MODEL, + { id: "deepseek-v4-pro", label: "deepseek-v4-pro" }, + { id: "deepseek-v4-flash", label: "deepseek-v4-flash" }, + ], + }, + { + id: "aider", + label: "Aider", + bin: "aider", + vendor: "Aider", + fallbackModels: [ + DEFAULT_MODEL, + { id: "claude-sonnet-4-5", label: "claude-sonnet-4-5" }, + { id: "gpt-5", label: "gpt-5" }, + { id: "deepseek/deepseek-chat", label: "deepseek/deepseek-chat" }, + ], + }, + { + id: "hermes", + label: "Hermes", + bin: "hermes", + envOverride: "HERMES_BIN", + vendor: "Mature", + protocol: "acp", + fallbackModels: [ + DEFAULT_MODEL, + { id: "openai-codex:gpt-5.5", label: "gpt-5.5 (openai-codex)" }, + { id: "openai-codex:gpt-5.4", label: "gpt-5.4 (openai-codex)" }, + ], + }, + { + id: "kimi", + label: "Kimi CLI", + bin: "kimi", + envOverride: "KIMI_BIN", + vendor: "Moonshot", + protocol: "acp", + fallbackModels: [ + DEFAULT_MODEL, + { id: "kimi-k2-turbo-preview", label: "kimi-k2-turbo-preview" }, + { id: "moonshot-v1-8k", label: "moonshot-v1-8k" }, + { id: "moonshot-v1-32k", label: "moonshot-v1-32k" }, + ], + }, + { + id: "devin", + label: "Devin for Terminal", + bin: "devin", + envOverride: "DEVIN_BIN", + vendor: "Cognition", + protocol: "acp", + fallbackModels: [ + DEFAULT_MODEL, + { id: "adaptive", label: "adaptive" }, + { id: "swe", label: "swe" }, + { id: "opus", label: "opus" }, + { id: "sonnet", label: "sonnet" }, + { id: "codex", label: "codex" }, + { id: "gpt", label: "gpt" }, + { id: "gemini", label: "gemini" }, + ], + }, + { + id: "kiro", + label: "Kiro CLI", + bin: "kiro-cli", + envOverride: "KIRO_BIN", + vendor: "AWS", + protocol: "acp", + fallbackModels: [DEFAULT_MODEL], + }, + { + id: "kilo", + label: "Kilo", + bin: "kilo", + envOverride: "KILO_BIN", + vendor: "Kilo", + protocol: "acp", + fallbackModels: [DEFAULT_MODEL], + }, + { + id: "vibe", + label: "Mistral Vibe CLI", + bin: "vibe-acp", + envOverride: "VIBE_BIN", + vendor: "Mistral", + protocol: "acp", + fallbackModels: [DEFAULT_MODEL], + }, + { + id: "pi", + label: "Pi", + bin: "pi", + envOverride: "PI_BIN", + vendor: "Inflection", + protocol: "pi-rpc", + fallbackModels: [ + DEFAULT_MODEL, + { id: "anthropic/claude-sonnet-4-5", label: "Claude Sonnet 4.5" }, + { id: "anthropic/claude-opus-4-5", label: "Claude Opus 4.5" }, + { id: "openai/gpt-5", label: "GPT-5" }, + { id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + ], + }, +]; + +function userToolchainDirs(): string[] { + const home = homedir(); + const env = process.env; + const dirs: string[] = []; + const vp = env.VP_HOME?.trim(); + if (vp) dirs.push(join(vp, "bin")); + const npmPrefix = env.NPM_CONFIG_PREFIX?.trim(); + if (npmPrefix) { + dirs.push(join(npmPrefix, "bin"), npmPrefix); + } + dirs.push( + join(home, ".local/bin"), + join(home, ".vite-plus/bin"), + join(home, ".opencode/bin"), + join(home, ".bun/bin"), + join(home, ".volta/bin"), + join(home, ".asdf/shims"), + join(home, "Library/pnpm"), + join(home, ".cargo/bin"), + join(home, ".npm-global/bin"), + join(home, ".npm-packages/bin"), + join(home, ".claude/local"), + ); + if (process.platform === "win32") { + const scoopRoot = env.SCOOP?.trim() || join(home, "scoop"); + const globalScoopRoot = env.SCOOP_GLOBAL?.trim() || "C:\\ProgramData\\scoop"; + const appData = env.APPDATA?.trim(); + dirs.push( + join(scoopRoot, "shims"), + join(scoopRoot, "apps", "nodejs", "current"), + join(scoopRoot, "apps", "nodejs-lts", "current"), + join(globalScoopRoot, "shims"), + join(globalScoopRoot, "apps", "nodejs", "current"), + ); + if (appData) dirs.push(join(appData, "npm")); + } else { + dirs.push("/opt/homebrew/bin", "/usr/local/bin"); + } + return dirs; +} + +export function resolveOnPath(bin: string): string | null { + const exts = + process.platform === "win32" + ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") + : [""]; + const seen = new Set(); + const dirs = [ + ...(process.env.PATH ?? "").split(delimiter), + ...userToolchainDirs(), + ].filter((d) => d && !seen.has(d) && (seen.add(d), true)); + for (const d of dirs) { + for (const e of exts) { + const full = path.join(d, bin + e); + try { + if (existsSync(full)) return full; + } catch {} + } + } + return null; +} + +export type DetectedAgent = { + id: string; + label: string; + vendor: string; + available: boolean; + path?: string; + resolvedBin?: string; + protocol: AgentProtocol; + models: ModelOption[]; + unsupported?: boolean; +}; + +export function detectAgents(): DetectedAgent[] { + return AGENTS.map((a): DetectedAgent => { + const protocol = a.protocol ?? "stdin"; + const unsupported = protocol === "acp" || protocol === "pi-rpc"; + const base = { + id: a.id, + label: a.label, + vendor: a.vendor, + protocol, + models: a.fallbackModels, + unsupported: unsupported || undefined, + }; + const override = a.envOverride ? process.env[a.envOverride] : undefined; + if (override && existsSync(override)) { + return { ...base, available: true, path: override, resolvedBin: a.bin }; + } + const candidates = [a.bin, ...(a.fallbackBins ?? [])]; + for (const c of candidates) { + const p = resolveOnPath(c); + if (p) { + return { ...base, available: true, path: p, resolvedBin: c }; + } + } + return { ...base, available: false }; + }); +} \ No newline at end of file diff --git a/cli/src/agents-invoke.ts b/cli/src/agents-invoke.ts new file mode 100644 index 0000000..9a90c0f --- /dev/null +++ b/cli/src/agents-invoke.ts @@ -0,0 +1,622 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolveOnPath, AGENTS, type AgentDef, type AgentProtocol } from "./agents-detect.js"; + +export type InvokeOpts = { + agent: string; + prompt: string; + cwd?: string; + model?: string; + signal?: AbortSignal; + binOverride?: string; +}; + +type BinResolution = + | { kind: "ok"; bin: string } + | { kind: "override-missing"; tried: string } + | { kind: "not-found" }; + +function resolveBinForAgent( + def: (typeof AGENTS)[number], + binOverride: string | undefined, +): BinResolution { + const tryPath = (p: string | undefined): string | null => { + if (!p) return null; + const trimmed = p.trim(); + if (!trimmed) return null; + if (/^([a-zA-Z]:[\\/]|[\\/])/.test(trimmed)) { + return existsSync(trimmed) ? trimmed : null; + } + return resolveOnPath(trimmed); + }; + if (binOverride && binOverride.trim()) { + const fromOverride = tryPath(binOverride); + if (fromOverride) return { kind: "ok", bin: fromOverride }; + return { kind: "override-missing", tried: binOverride.trim() }; + } + if (def.envOverride) { + const fromEnv = tryPath(process.env[def.envOverride]); + if (fromEnv) return { kind: "ok", bin: fromEnv }; + } + for (const c of [def.bin, ...(def.fallbackBins ?? [])]) { + const found = resolveOnPath(c); + if (found) return { kind: "ok", bin: found }; + } + return { kind: "not-found" }; +} + +export type InvokeEvent = + | { type: "start"; bin: string; argv: string[]; promptBytes: number } + | { type: "delta"; text: string } + | { type: "html"; text: string } + | { type: "meta"; key: string; value: unknown } + | { type: "stderr"; text: string } + | { type: "raw"; text: string } + | { type: "done"; code: number | null } + | { type: "error"; message: string }; + +// ─── argv builder ──────────────────────────────────────────────────── + +type AgentArgvOpts = { + model?: string; + openclawAgentId?: string; +}; + +class UnsupportedAgentProtocolError extends Error { + constructor(public readonly agent: string, public readonly protocol: string) { + super( + `${agent} uses the ${protocol} protocol, which is not yet wired up in this build. ` + + `Pick one of: claude / codex / cursor-agent / gemini / copilot / opencode / qwen / qoder / deepseek / aider.`, + ); + } +} + +function buildArgv(agent: string, opts: AgentArgvOpts = {}): string[] { + const { model } = opts; + switch (agent) { + case "claude": + return [ + "-p", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--permission-mode", + "bypassPermissions", + ...(model ? ["--model", model] : []), + ]; + case "openclaw": + return [ + "agent", + "--local", + "--json", + "--agent", + opts.openclawAgentId ?? "main", + ...(model ? ["--model", model] : []), + ]; + case "codex": + return [ + "exec", + "--json", + "--skip-git-repo-check", + "--sandbox", + "workspace-write", + "-c", + "sandbox_workspace_write.network_access=true", + ...(model ? ["--model", model] : []), + ]; + case "cursor-agent": + return [ + "--print", + "--output-format", + "stream-json", + "--stream-partial-output", + "--force", + "--trust", + ...(model ? ["--model", model] : []), + ]; + case "gemini": + return [ + "--output-format", + "stream-json", + "--yolo", + ...(model ? ["--model", model] : []), + ]; + case "copilot": + return [ + "--allow-all-tools", + "--output-format", + "json", + ...(model ? ["--model", model] : []), + ]; + case "opencode": + return [ + "run", + "--format", + "json", + "--dangerously-skip-permissions", + ...(model ? ["--model", model] : []), + "-", + ]; + case "qwen": + return ["--yolo", ...(model ? ["--model", model] : []), "-"]; + case "aider": + return [ + "--no-pretty", + "--no-stream", + "--yes-always", + "--message-file", + "-", + ...(model ? ["--model", model] : []), + ]; + case "qoder": + return [ + "-p", + "--output-format", + "stream-json", + "--yolo", + ...(model ? ["--model", model] : []), + ]; + case "deepseek": + return ["exec", "--auto", ...(model ? ["--model", model] : [])]; + case "hermes": + case "kimi": + case "devin": + case "kiro": + case "kilo": + case "vibe": + throw new UnsupportedAgentProtocolError(agent, "ACP JSON-RPC"); + case "pi": + throw new UnsupportedAgentProtocolError(agent, "pi-rpc"); + default: + throw new Error(`unknown agent: ${agent}`); + } +} + +function envFor(agent: string): NodeJS.ProcessEnv { + const base = { ...process.env }; + if (agent === "gemini") base.GEMINI_CLI_TRUST_WORKSPACE = "true"; + return base; +} + +// ─── stdout parser ──────────────────────────────────────────────────── + +type AgentParse = + | { kind: "delta"; text: string } + | { kind: "meta"; key: string; value: unknown } + | { kind: "html"; text: string } + | { kind: "noise" }; + +type ParseState = { sawStreamEventText?: boolean }; + +function rescueHtmlFromToolUse( + content: Array<{ type?: string; name?: string; input?: unknown }> | undefined, +): string { + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content) { + if (!block || block.type !== "tool_use") continue; + const name = (block.name ?? "").toLowerCase(); + if ( + name !== "write" && + name !== "create_file" && + name !== "createfile" && + name !== "writefile" && + name !== "write_file" && + name !== "filewrite" + ) + continue; + const input = block.input as Record | undefined; + if (!input || typeof input !== "object") continue; + const path = String(input.file_path ?? input.path ?? input.filename ?? "").toLowerCase(); + if (path && !/\.(html?|htm)$/.test(path)) continue; + const text = + typeof input.content === "string" + ? input.content + : typeof input.text === "string" + ? input.text + : typeof input.file_content === "string" + ? input.file_content + : ""; + if (text) parts.push(text); + } + return parts.join(""); +} + +function parseLineWithState(agent: string, line: string, state: ParseState): AgentParse[] { + const trimmed = line.trim(); + if (!trimmed) return []; + + if (agent === "aider" || agent === "deepseek") { + return [{ kind: "delta", text: trimmed.endsWith("\n") ? trimmed : trimmed + "\n" }]; + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return [{ kind: "noise" }]; + } + if (!parsed || typeof parsed !== "object") return []; + const obj = parsed as Record; + const out: AgentParse[] = []; + + if (agent === "claude") { + if (obj.type === "system" && obj.subtype === "init") { + out.push({ kind: "meta", key: "model", value: obj.model }); + out.push({ kind: "meta", key: "session", value: obj.session_id }); + if (obj.cwd) out.push({ kind: "meta", key: "cwd", value: obj.cwd }); + } + if (obj.type === "stream_event" && obj.event && typeof obj.event === "object") { + const ev = obj.event as { type?: string; delta?: { type?: string; text?: string; thinking?: string } }; + if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta" && typeof ev.delta.text === "string") { + state.sawStreamEventText = true; + out.push({ kind: "delta", text: ev.delta.text }); + } else if (ev.type === "content_block_delta" && ev.delta?.type === "thinking_delta") { + out.push({ kind: "meta", key: "thinking", value: ev.delta.thinking }); + } + } + if (obj.type === "assistant" && obj.message && typeof obj.message === "object") { + const msg = obj.message as { + content?: Array<{ type?: string; text?: string; name?: string; input?: unknown }>; + usage?: Record; + model?: string; + }; + const toolHtml = rescueHtmlFromToolUse(msg.content); + if (toolHtml) { + out.push({ kind: "html", text: toolHtml }); + state.sawStreamEventText = true; + } + if (!state.sawStreamEventText) { + const text = (msg.content ?? []) + .filter((c) => c?.type === "text" && typeof c.text === "string") + .map((c) => c.text!) + .join(""); + if (text) out.push({ kind: "delta", text }); + } + if (msg.usage) out.push({ kind: "meta", key: "usage_partial", value: msg.usage }); + } + if (obj.type === "result") { + if (obj.usage) out.push({ kind: "meta", key: "usage", value: obj.usage }); + if (typeof obj.duration_ms === "number") out.push({ kind: "meta", key: "duration_ms", value: obj.duration_ms }); + if (typeof obj.total_cost_usd === "number") out.push({ kind: "meta", key: "cost_usd", value: obj.total_cost_usd }); + if (typeof obj.subtype === "string") out.push({ kind: "meta", key: "result", value: obj.subtype }); + } + } + + if (agent === "codex") { + if (obj.type === "item.completed" && obj.item && typeof obj.item === "object") { + const item = obj.item as { item_type?: string; type?: string; text?: string }; + const itemType = item.item_type ?? item.type; + if ( + (itemType === "assistant_message" || itemType === "agent_message") && + typeof item.text === "string" + ) { + out.push({ kind: "delta", text: item.text }); + } + } + if (obj.type === "item.delta" && typeof obj.text === "string") { + out.push({ kind: "delta", text: obj.text }); + } + if (obj.msg && typeof obj.msg === "object") { + const msg = obj.msg as { type?: string; message?: string }; + if (msg.type === "agent_message" && typeof msg.message === "string") { + out.push({ kind: "delta", text: msg.message }); + } + } + if (obj.type === "task_complete" && obj.usage) { + out.push({ kind: "meta", key: "usage", value: obj.usage }); + } + if (obj.type === "turn.completed" && obj.usage) { + out.push({ kind: "meta", key: "usage", value: obj.usage }); + } + } + + if (agent === "cursor-agent" || agent === "gemini") { + if (obj.type === "stream_event" && obj.event && typeof obj.event === "object") { + const ev = obj.event as { type?: string; delta?: { type?: string; text?: string } }; + if (ev.delta?.type === "text_delta" && typeof ev.delta.text === "string") { + state.sawStreamEventText = true; + out.push({ kind: "delta", text: ev.delta.text }); + } + } + if (obj.type === "assistant" && obj.message && typeof obj.message === "object") { + const msg = obj.message as { content?: Array<{ type?: string; text?: string; name?: string; input?: unknown }> }; + const toolHtml = rescueHtmlFromToolUse(msg.content); + if (toolHtml) { + out.push({ kind: "html", text: toolHtml }); + state.sawStreamEventText = true; + } + if (!state.sawStreamEventText) { + const text = (msg.content ?? []) + .filter((c) => c?.type === "text" && typeof c.text === "string") + .map((c) => c.text!) + .join(""); + if (text) out.push({ kind: "delta", text }); + } + } + if (typeof obj.text === "string" && !state.sawStreamEventText && obj.type !== "assistant") { + out.push({ kind: "delta", text: obj.text as string }); + } + } + + if (agent === "copilot") { + if (typeof obj.response === "string") out.push({ kind: "delta", text: obj.response }); + if (typeof obj.text === "string") out.push({ kind: "delta", text: obj.text }); + } + + if (agent === "opencode" || agent === "qwen") { + if (typeof obj.text === "string") out.push({ kind: "delta", text: obj.text }); + if (typeof obj.content === "string") out.push({ kind: "delta", text: obj.content }); + if (typeof obj.message === "string") out.push({ kind: "delta", text: obj.message }); + } + + if (agent === "qoder") { + if (obj.type === "system" && obj.subtype === "init") { + if (obj.model) out.push({ kind: "meta", key: "model", value: obj.model }); + if (obj.session_id) out.push({ kind: "meta", key: "session", value: obj.session_id }); + } + if (obj.type === "stream_event" && obj.event && typeof obj.event === "object") { + const ev = obj.event as { type?: string; delta?: { type?: string; text?: string } }; + if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta" && typeof ev.delta.text === "string") { + state.sawStreamEventText = true; + out.push({ kind: "delta", text: ev.delta.text }); + } + } + if (obj.type === "assistant" && obj.message && typeof obj.message === "object") { + const msg = obj.message as { content?: Array<{ type?: string; text?: string; name?: string; input?: unknown }> }; + const toolHtml = rescueHtmlFromToolUse(msg.content); + if (toolHtml) { + out.push({ kind: "html", text: toolHtml }); + state.sawStreamEventText = true; + } + if (!state.sawStreamEventText) { + const text = (msg.content ?? []) + .filter((c) => c?.type === "text" && typeof c.text === "string") + .map((c) => c.text!) + .join(""); + if (text) out.push({ kind: "delta", text }); + } + } + if (obj.type === "result") { + if (obj.usage) out.push({ kind: "meta", key: "usage", value: obj.usage }); + if (typeof obj.duration_ms === "number") out.push({ kind: "meta", key: "duration_ms", value: obj.duration_ms }); + } + if (typeof obj.text === "string" && !state.sawStreamEventText && obj.type !== "assistant") { + out.push({ kind: "delta", text: obj.text }); + } + } + + return out; +} + +function makeParser(agent: string): (line: string) => AgentParse[] { + const state: ParseState = {}; + return (line: string) => parseLineWithState(agent, line, state); +} + +// ─── resolve OpenClaw agent id ──────────────────────────────────────── + +let openclawAgentIdCache: { value: string; expiresAt: number } | null = null; + +async function resolveOpenclawAgentId(bin: string): Promise { + const now = Date.now(); + if (openclawAgentIdCache && openclawAgentIdCache.expiresAt > now) { + return openclawAgentIdCache.value; + } + let resolved = "main"; + try { + const { spawn: spawnAsync } = await import("node:child_process"); + const out = await new Promise((res, rej) => { + const child = spawnAsync(bin, ["agents", "list"], { + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + }); + let buf = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (c: string) => (buf += c)); + child.on("close", () => res(buf)); + child.on("error", rej); + setTimeout(() => { + try { child.kill("SIGTERM"); } catch {} + rej(new Error("openclaw agents list timed out")); + }, 5_000); + }); + const m = out.match(/^- (\S+)/m); + if (m && m[1]) resolved = m[1]; + } catch {} + openclawAgentIdCache = { value: resolved, expiresAt: now + 5 * 60_000 }; + return resolved; +} + +// ─── main invoke function ───────────────────────────────────────────── + +export function invokeAgent(opts: InvokeOpts): ReadableStream { + const def = AGENTS.find((a) => a.id === opts.agent); + if (!def) { + return errorStream(`unknown agent: ${opts.agent}`); + } + const resolved = resolveBinForAgent(def, opts.binOverride); + if (resolved.kind === "override-missing") { + return errorStream( + `${def.label}: custom path \`${resolved.tried}\` does not exist.`, + ); + } + if (resolved.kind === "not-found") { + return errorStream( + `${def.label} (\`${def.bin}\`) is not installed or not on PATH.`, + ); + } + const bin: string = resolved.bin; + + const env = envFor(opts.agent); + const promptViaArgv = def.protocol === "argv"; + const promptViaMessageFlag = def.protocol === "argv-message"; + + return new ReadableStream({ + async start(controller) { + let closed = false; + let child: ChildProcessWithoutNullStreams | null = null; + + const safeEnqueue = (ev: InvokeEvent) => { + if (closed) return; + try { + controller.enqueue(ev); + } catch { + closed = true; + } + }; + const safeClose = () => { + if (closed) return; + closed = true; + try { + controller.close(); + } catch {} + }; + + let argv: string[]; + try { + const argvOpts: AgentArgvOpts = { + model: opts.model, + }; + if (opts.agent === "openclaw") { + argvOpts.openclawAgentId = await resolveOpenclawAgentId(bin); + } + argv = buildArgv(opts.agent, argvOpts); + } catch (err) { + safeEnqueue({ + type: "error", + message: + err instanceof UnsupportedAgentProtocolError + ? err.message + : err instanceof Error + ? err.message + : String(err), + }); + safeClose(); + return; + } + if (promptViaArgv) argv = [...argv, opts.prompt]; + if (promptViaMessageFlag) argv = [...argv, "--message", opts.prompt]; + + try { + child = spawn(bin, argv, { + cwd: opts.cwd ?? process.cwd(), + env, + stdio: ["pipe", "pipe", "pipe"], + shell: process.platform === "win32", + }); + } catch (err) { + safeEnqueue({ + type: "error", + message: err instanceof Error ? err.message : String(err), + }); + safeClose(); + return; + } + + safeEnqueue({ + type: "start", + bin, + argv, + promptBytes: Buffer.byteLength(opts.prompt, "utf8"), + }); + + child.stdin.on("error", () => {}); + try { + if (!promptViaArgv && !promptViaMessageFlag) child.stdin.write(opts.prompt); + child.stdin.end(); + } catch {} + + const parse = makeParser(opts.agent); + + let stdoutBuf = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + if (closed) return; + stdoutBuf += chunk; + if (opts.agent === "openclaw") return; + let nl: number; + while ((nl = stdoutBuf.indexOf("\n")) !== -1) { + const line = stdoutBuf.slice(0, nl); + stdoutBuf = stdoutBuf.slice(nl + 1); + if (!line) continue; + for (const part of parse(line)) { + if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); + else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); + else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); + else safeEnqueue({ type: "raw", text: line.slice(0, 240) }); + } + } + }); + + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + safeEnqueue({ type: "stderr", text: chunk }); + }); + + child.on("error", (err) => { + safeEnqueue({ type: "error", message: err.message }); + safeClose(); + }); + + child.on("close", (code) => { + if (opts.agent === "openclaw") { + if (stdoutBuf.trim()) { + try { + const obj = JSON.parse(stdoutBuf) as { + payloads?: Array<{ text?: string }>; + meta?: { + finalAssistantVisibleText?: string; + finalAssistantRawText?: string; + executionTrace?: { winnerProvider?: string; winnerModel?: string }; + completion?: { stopReason?: string }; + agentMeta?: { sessionId?: string }; + }; + }; + const text = obj?.meta?.finalAssistantVisibleText + ?? obj?.meta?.finalAssistantRawText + ?? obj?.payloads?.[0]?.text + ?? ""; + if (text) safeEnqueue({ type: "delta", text }); + } catch (err) { + safeEnqueue({ + type: "error", + message: `OpenClaw JSON parse failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } + } else if (stdoutBuf) { + for (const part of parse(stdoutBuf)) { + if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); + else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); + else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); + } + if (opts.agent === "aider" || opts.agent === "deepseek") { + safeEnqueue({ type: "delta", text: stdoutBuf }); + } + } + safeEnqueue({ type: "done", code }); + safeClose(); + }); + + const onAbort = () => { + try { + child?.kill("SIGTERM"); + } catch {} + safeClose(); + }; + opts.signal?.addEventListener("abort", onAbort, { once: true }); + }, + cancel() {}, + }); +} + +function errorStream(message: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue({ type: "error", message }); + controller.close(); + }, + }); +} \ No newline at end of file diff --git a/cli/src/config.ts b/cli/src/config.ts new file mode 100644 index 0000000..8644f96 --- /dev/null +++ b/cli/src/config.ts @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +export interface CliConfig { + defaultTemplate?: string; + defaultAgent?: string; + model?: string; +} + +const CONFIG_DIR = path.join(os.homedir(), ".config", "html-anything"); +const CONFIG_PATH = path.join(CONFIG_DIR, "config.json"); + +function ensureConfigDir(): void { + if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + } +} + +export function loadConfig(): CliConfig { + try { + if (fs.existsSync(CONFIG_PATH)) { + const raw = fs.readFileSync(CONFIG_PATH, "utf-8"); + return JSON.parse(raw) as CliConfig; + } + } catch { + } + return {}; +} + +export function saveConfig(config: CliConfig): void { + ensureConfigDir(); + const merged = { ...loadConfig(), ...config }; + fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2), "utf-8"); +} + +export function getConfigPath(): string { + return CONFIG_PATH; +} \ No newline at end of file diff --git a/cli/src/extract-html.ts b/cli/src/extract-html.ts new file mode 100644 index 0000000..09cb75b --- /dev/null +++ b/cli/src/extract-html.ts @@ -0,0 +1,46 @@ +/** + * Extract HTML from agent output — adapted from next/src/lib/extract-html.ts + */ + +export function extractHtml(streamed: string): string { + if (!streamed) return ""; + + const fence = streamed.match(/```(?:html|HTML)?\s*([\s\S]*?)```/); + if (fence) { + const inner = fence[1].trim(); + if (inner.startsWith("<")) return inner; + } + + const doctypeStart = streamed.search(/"); + if (closeIdx !== -1) { + return streamed.slice(doctypeStart, closeIdx + "".length); + } + return streamed.slice(doctypeStart); + } + + const htmlStart = streamed.search(/]/i); + if (htmlStart !== -1) { + const closeIdx = streamed.lastIndexOf(""); + if (closeIdx !== -1) { + return streamed.slice(htmlStart, closeIdx + "".length); + } + return streamed.slice(htmlStart); + } + + if (streamed.trimStart().startsWith("<")) { + return streamed; + } + + return `
${escape(
+    streamed,
+  )}
`; +} + +function escape(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">"); +} \ No newline at end of file diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..5467b72 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,471 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadSkill, listSkills, type SkillMeta, type LoadedSkill } from "./skills-loader.js"; +import { detectAgents, type DetectedAgent } from "./agents-detect.js"; +import { assemblePrompt } from "./prompt-assemble.js"; +import { invokeAgent, type InvokeEvent } from "./agents-invoke.js"; +import { extractHtml } from "./extract-html.js"; +import { loadConfig, saveConfig, getConfigPath, type CliConfig } from "./config.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function findWorkspaceRoot(): string { + let dir = __dirname; + while (true) { + if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return process.cwd(); +} + +const WORKSPACE_ROOT = findWorkspaceRoot(); +const SKILLS_DIR = path.join(WORKSPACE_ROOT, "next", "src", "lib", "templates", "skills"); + +export function getSkillsDir(): string { + return SKILLS_DIR; +} + +export function getAvailableTemplates(): SkillMeta[] { + return listSkills(SKILLS_DIR); +} + +export function getTemplate(id: string): LoadedSkill | null { + return loadSkill(SKILLS_DIR, id); +} + +export function getAvailableAgents(): DetectedAgent[] { + return detectAgents(); +} + +function findAgent(agentId?: string): DetectedAgent | null { + const agents = getAvailableAgents(); + if (agentId) { + return agents.find((a) => a.id === agentId && a.available) ?? null; + } + const config = loadConfig(); + if (config.defaultAgent) { + const found = agents.find((a) => a.id === config.defaultAgent && a.available); + if (found) return found; + } + return agents.find((a) => a.available && !a.unsupported) ?? null; +} + +function printHelp(): void { + console.log(`html-anything — AI-powered Markdown to HTML converter (CLI) + +USAGE: + html-anything [options] + +COMMANDS: + convert [input] Convert Markdown to HTML + input Input file (markdown), or use stdin if omitted + --template, -t Template ID (default: uses saved default) + --agent, -a Agent ID (default: auto-detect) + --output, -o Output file path (default: auto-save to .html or stdout) + --output-dir, -d Output directory for auto-saved files (default: current dir) + --model Model to use (optional) + --format Input format: markdown, text, csv, json (default: markdown) + + templates List all available templates + + agents List detected AI agents + + config Show current configuration + config set-default-template Set the default template + config set-default-agent Set the default AI agent + config set-model Set the default model + config reset Reset all configuration + +EXAMPLES: + html-anything convert article.md + html-anything convert article.md -t doc-kami-parchment -o output.html + html-anything convert article.md -t doc-kami-parchment -d ./dist + html-anything convert article.md -a claude --model sonnet + cat article.md | html-anything convert + html-anything config set-default-template resume-modern + html-anything templates + html-anything agents +`); +} + +function createSpinner(msg: string) { + if (!process.stderr.isTTY) { + const start = Date.now(); + let chunkCount = 0; + return { + tick: () => { chunkCount++; }, + start, + stop: (_final?: string) => {}, + }; + } + + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let i = 0; + let chunkCount = 0; + const start = Date.now(); + let lastLen = 0; + + const interval = setInterval(() => { + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + const frame = frames[i % frames.length]; + const text = `\r ${frame} ${msg} ${chunkCount} chunks / ${elapsed}s \x1b[90m(Ctrl+C to stop)\x1b[0m`; + process.stderr.write(text); + lastLen = text.length; + i++; + }, 80); + + return { + tick: () => { chunkCount++; }, + start, + stop: (final?: string) => { + clearInterval(interval); + process.stderr.write("\r" + " ".repeat(lastLen) + "\r"); + if (final !== undefined) process.stderr.write(`${final}\n`); + }, + }; +} + +async function handleConvert(args: string[]): Promise { + const flags: Record = {}; + const positional: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--template" || arg === "-t") { + flags.template = args[++i] ?? ""; + } else if (arg === "--agent" || arg === "-a") { + flags.agent = args[++i] ?? ""; + } else if (arg === "--output" || arg === "-o") { + flags.output = args[++i] ?? ""; + } else if (arg === "--output-dir" || arg === "-d") { + flags.outputDir = args[++i] ?? ""; + } else if (arg === "--model") { + flags.model = args[++i] ?? ""; + } else if (arg === "--format") { + flags.format = args[++i] ?? ""; + } else if (arg === "--help" || arg === "-h") { + printHelp(); + return; + } else if (!arg.startsWith("-")) { + positional.push(arg); + } else { + console.error(`Unknown option: ${arg}`); + process.exit(1); + } + } + + const config = loadConfig(); + + const templateId = flags.template ?? config.defaultTemplate; + if (!templateId) { + console.error("Error: No template specified. Use --template or set a default with:"); + console.error(" html-anything config set-default-template "); + console.error("\nAvailable templates:"); + for (const t of getAvailableTemplates()) { + console.error(` ${t.id} — ${t.zhName}`); + } + process.exit(1); + } + + const skill = getTemplate(templateId); + if (!skill) { + console.error(`Error: Unknown template "${templateId}"`); + console.error("Run 'html-anything templates' to list available templates."); + process.exit(1); + } + + const agent = findAgent(flags.agent); + if (!agent) { + const wantId = flags.agent ?? config.defaultAgent ?? "(auto-detect)"; + console.error(`Error: No available AI agent found${flags.agent ? ` for "${wantId}"` : ""}.`); + console.error("\nDetected agents:"); + for (const a of getAvailableAgents()) { + const status = a.available ? (a.unsupported ? "(unsupported)" : "✓") : "✗"; + console.error(` ${status} ${a.id} — ${a.label}`); + } + console.error("\nInstall one of the supported agents (e.g. 'claude', 'codex', 'gemini') and try again."); + process.exit(1); + } + + const format = flags.format ?? "markdown"; + let content: string; + let inputPath: string | null = null; + + if (positional.length > 0) { + inputPath = positional[0]; + try { + content = fs.readFileSync(inputPath, "utf-8"); + } catch (err) { + console.error(`Error: Cannot read input file "${inputPath}": ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + } else { + content = await readStdin(); + if (!content.trim()) { + console.error("Error: No input provided. Pipe content via stdin or specify an input file."); + process.exit(1); + } + } + + const prompt = assemblePrompt({ body: skill.body, content, format }); + + const model = flags.model ?? config.model; + + console.error(`Template: ${skill.zhName} (${skill.id})`); + console.error(`Agent: ${agent.label} (${agent.id})`); + if (model) console.error(`Model: ${model}`); + console.error(""); + + const stream = invokeAgent({ + agent: agent.id, + prompt, + model, + }); + + const reader = stream.getReader(); + let htmlAccum = ""; + let hasHtmlFromTool = false; + const spinner = createSpinner("Generating HTML..."); + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (!value) continue; + + switch (value.type) { + case "delta": + htmlAccum += value.text; + spinner.tick(); + break; + case "html": + htmlAccum = value.text; + hasHtmlFromTool = true; + spinner.tick(); + break; + case "error": + spinner.stop(`\x1b[31m✗\x1b[0m Error: ${value.message}`); + process.exit(1); + case "meta": + break; + case "stderr": + break; + case "done": + break; + } + } + } catch (err) { + spinner.stop(`\x1b[31m✗\x1b[0m Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + + const elapsed = ((Date.now() - spinner.start) / 1000).toFixed(1); + spinner.stop(`\x1b[32m✓\x1b[0m Done in ${elapsed}s`); + + const html = extractHtml(htmlAccum); + + if (!html) { + console.error("Error: Agent did not produce valid HTML output."); + console.error("Raw output:\n", htmlAccum.slice(0, 500)); + process.exit(1); + } + + if (flags.output) { + try { + fs.mkdirSync(path.dirname(path.resolve(flags.output)), { recursive: true }); + fs.writeFileSync(flags.output, html, "utf-8"); + console.error(`Saved to: ${flags.output}`); + } catch (err) { + console.error(`Error: Cannot write to "${flags.output}": ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + } else if (inputPath) { + const basename = path.basename(inputPath, path.extname(inputPath)); + const outputDir = flags.outputDir || process.cwd(); + const outputPath = path.resolve(outputDir, `${basename}.html`); + try { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, html, "utf-8"); + console.error(`Saved to: ${outputPath}`); + } catch (err) { + console.error(`Error: Cannot write to "${outputPath}": ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + } else { + process.stdout.write(html); + } +} + +function readStdin(): Promise { + return new Promise((resolve) => { + if (process.stdin.isTTY) { + resolve(""); + return; + } + const chunks: Buffer[] = []; + process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk)); + process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + process.stdin.on("error", () => resolve("")); + }); +} + +function handleTemplates(): void { + const templates = getAvailableTemplates(); + + if (templates.length === 0) { + console.log("No templates found."); + return; + } + + const config = loadConfig(); + console.log(`Available templates (${templates.length}):\n`); + + const byCategory: Record = {}; + for (const t of templates) { + const cat = t.category || "other"; + if (!byCategory[cat]) byCategory[cat] = []; + byCategory[cat].push(t); + } + + for (const [category, skills] of Object.entries(byCategory)) { + console.log(`[${category}]`); + for (const s of skills) { + const isDefault = s.id === config.defaultTemplate ? " (default)" : ""; + console.log(` ${s.emoji} ${s.id} — ${s.zhName}${isDefault}`); + } + console.log(); + } +} + +function handleAgents(): void { + const agents = getAvailableAgents(); + const config = loadConfig(); + + if (agents.length === 0) { + console.log("No agents detected."); + return; + } + + console.log("Detected AI agents:\n"); + + for (const a of agents) { + const status = a.available + ? a.unsupported + ? "⚠ (unsupported)" + : "✓" + : "✗"; + const isDefault = a.id === config.defaultAgent ? " (default)" : ""; + console.log(` ${status} ${a.id} — ${a.label} (${a.vendor})${isDefault}`); + } +} + +function handleConfig(args: string[]): void { + if (args.length === 0) { + const config = loadConfig(); + console.log("Current configuration:"); + if (Object.keys(config).length === 0) { + console.log(" (no configuration set)"); + } else { + if (config.defaultTemplate) { + const t = getTemplate(config.defaultTemplate); + console.log(` default-template: ${config.defaultTemplate}${t ? ` (${t.zhName})` : ""}`); + } + if (config.defaultAgent) console.log(` default-agent: ${config.defaultAgent}`); + if (config.model) console.log(` model: ${config.model}`); + } + console.log(`\nConfig file: ${getConfigPath()}`); + return; + } + + const sub = args[0]; + const val = args[1]; + + switch (sub) { + case "set-default-template": { + if (!val) { + console.error("Error: Specify a template ID."); + process.exit(1); + } + const skill = getTemplate(val); + if (!skill) { + console.error(`Error: Unknown template "${val}"`); + process.exit(1); + } + saveConfig({ defaultTemplate: val }); + console.log(`Default template set to: ${val} (${skill.zhName})`); + break; + } + case "set-default-agent": { + if (!val) { + console.error("Error: Specify an agent ID."); + process.exit(1); + } + const agents = getAvailableAgents(); + const agent = agents.find((a) => a.id === val); + if (!agent) { + console.error(`Error: Unknown agent "${val}"`); + process.exit(1); + } + saveConfig({ defaultAgent: val }); + console.log(`Default agent set to: ${val} (${agent.label})`); + break; + } + case "set-model": { + if (!val) { + console.error("Error: Specify a model ID."); + process.exit(1); + } + saveConfig({ model: val }); + console.log(`Default model set to: ${val}`); + break; + } + case "reset": { + saveConfig({ defaultTemplate: undefined, defaultAgent: undefined, model: undefined }); + console.log("Configuration reset."); + break; + } + default: + console.error(`Unknown config command: ${sub}`); + console.error("Available: set-default-template, set-default-agent, set-model, reset"); + process.exit(1); + } +} + +export async function main(args: string[]): Promise { + if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { + printHelp(); + return; + } + + const command = args[0]; + const rest = args.slice(1); + + switch (command) { + case "convert": + await handleConvert(rest); + break; + case "templates": + handleTemplates(); + break; + case "agents": + handleAgents(); + break; + case "config": + handleConfig(rest); + break; + case "--version": + case "-v": + console.log("html-anything CLI v0.1.0"); + break; + default: + console.error(`Unknown command: ${command}`); + console.error("Run 'html-anything --help' for usage information."); + process.exit(1); + } +} \ No newline at end of file diff --git a/cli/src/prompt-assemble.ts b/cli/src/prompt-assemble.ts new file mode 100644 index 0000000..0c6c9f9 --- /dev/null +++ b/cli/src/prompt-assemble.ts @@ -0,0 +1,51 @@ +/** + * Prompt assembly — adapted from next/src/lib/templates/shared.ts + */ + +const SHARED_DESIGN_DIRECTIVES = ` +你是世界级的视觉设计师 + 资深前端工程师。请输出一份**自包含的单文件 HTML**,要求: + +【内容驱动数量 — 最高优先级, 覆盖模板里的任何数字】 +- 模板只定义"可用版面 / 风格 / 配色 / 字体 / 组件库", **不定义** slide / 帧 / 卡片 / section 的数量。 +- 输出的 slide / frame / card / section 数量**完全由【用户内容】的实际长度和信息结构决定**。必须**完整覆盖**用户内容的每一个要点、章节、数据组, **不许总结、压缩、丢弃信息**。 +- 如果模板正文里写了类似"挑 6-10 张组成 deck / 输出 6-10 帧 / 3-6 张卡片"的数字, **一律视为短示例下的参考下限, 不是上限**。短内容可以低于该范围, 长内容应远超该范围 — 用户给了 12k 字符的内容, 输出 4-6 张是**严重错误**。 +- 模板里的"N 个锁死版面 / N 个磁带式版面 / N 个 layout"指的是**可复用的版式池**, 同一个版式允许在不同内容上多次出现 (例如 KPI Tower 可以连续用 3 次承载不同章节的数据), 不是页数上限。 +- 推荐做法: 先把【用户内容】按语义切成若干段 (章节标题 / 论点 / 数据组 / 列表项 / 步骤), 每一段 → 至少一个独立的 slide / section / card, 然后再从模板的版式池里给每一段挑最合适的版面。宁可多页也不要把多个独立要点硬塞进一页。 + +【硬性技术要求】 +- **禁止使用 Write / Edit / MultiEdit / Bash / Create / 任何文件系统工具**。不要把 HTML 写到任何 \`.html\` 文件里。前端直接捕获你的 stdout 文本, 文件落盘由前端负责。 +- 直接把完整的 HTML 文档作为助手回复的正文流式输出。不要先说"我来生成"、"已输出至 …"之类的话。 +- 文档以 \`\` 开头, 末尾以 \`\` 结束。 +- 在 \`\` 中通过 CDN 引入 Tailwind v3 Play (https://cdn.tailwindcss.com) 与所需的 Google Fonts。 +- 不要引用任何外部图片 URL(除非你能保证 URL 长期有效;优先使用 CSS / SVG 内联绘制)。 +- 必要的脚本(图表、动画)通过 jsdelivr CDN 引入;保持单文件可双击打开即用。 +- 输出**纯 HTML**, 不要用 markdown 代码围栏包裹, 不要任何解释性文字。第一个字符必须是 \`<\`。 + +【设计准则 — 世界级标准】 +- 排版: 中文优先 \`Noto Sans SC\` / \`Noto Serif SC\`, 英文 \`Inter\` / \`Manrope\` / \`SF Pro\` 风格。 +- 色彩: 使用 1 个主色 + 2 个中性色 + 至多 1 个强调色; 大胆留白; 不使用纯黑纯白 (#000/#fff), 改用 \`#0a0a0a\` / \`#fafafa\`。 +- 网格: 8 px 基线; 段落最大宽度 65 ch; 标题与正文有清晰的层级。 +- 微观细节: 圆角统一 (rounded-xl/2xl), 投影柔和 (shadow-sm/lg), 边框 1px \`#e5e7eb\` / \`#262626\`。 +- 动效: 仅在必要处使用 \`transition-all\` 或入场 fade-in; 不要喧宾夺主。 +- 无障碍: 颜色对比度 ≥ 4.5; 重要交互有 focus 态。 + +【内容真实性】 +- **必须使用用户提供的真实数据**, 不要编造、不要 lorem ipsum、不要 "Your text here"。 +- 如果用户数据是结构化数据 (CSV/JSON), 请提取关键洞察并以图表/表格呈现。 +- 中文与英文混排时, 中英文之间留半角空格 (盘古之白)。 + +`; + +export function assemblePrompt(opts: { + body: string; + content: string; + format: string; +}): string { + return `${SHARED_DESIGN_DIRECTIVES} +${opts.body.trim()} + +【输入格式】: ${opts.format} +【用户内容】: +${opts.content} +`; +} \ No newline at end of file diff --git a/cli/src/run.ts b/cli/src/run.ts new file mode 100644 index 0000000..f64cd16 --- /dev/null +++ b/cli/src/run.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +import { main } from "./index.js"; +main(process.argv.slice(2)); \ No newline at end of file diff --git a/cli/src/skills-loader.ts b/cli/src/skills-loader.ts new file mode 100644 index 0000000..22faf60 --- /dev/null +++ b/cli/src/skills-loader.ts @@ -0,0 +1,201 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * File-based skill loader — adapted from next/src/lib/templates/loader.ts + * This version accepts a skillsDir parameter instead of hardcoding process.cwd(). + */ + +export type SkillFrontmatter = { + name?: string; + zh_name?: string; + en_name?: string; + emoji?: string; + description?: string; + category?: string; + scenario?: string; + aspect_hint?: string; + featured?: number; + recommended?: number; + tags?: string[]; + example_id?: string; + example_name?: string; + example_format?: string; + example_tagline?: string; + example_desc?: string; + example_source_url?: string; + example_source_label?: string; +}; + +export type SkillExampleMeta = { + id: string; + name: string; + format: string; + tagline: string; + desc: string; + source?: { url: string; label: string }; + hasHtml: boolean; + hasMd: boolean; +}; + +export type SkillMeta = { + id: string; + zhName: string; + enName: string; + emoji: string; + description: string; + category: string; + scenario: string; + aspectHint: string; + featured?: number; + recommended?: number; + tags: string[]; + example?: SkillExampleMeta; +}; + +export type LoadedSkill = SkillMeta & { + body: string; + exampleMd?: string; + exampleHtml?: string; +}; + +function parseFrontmatter(raw: string): { fm: SkillFrontmatter; body: string } { + const m = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/m.exec(raw); + if (!m) return { fm: {}, body: raw }; + const block = m[1]; + const body = m[2] ?? ""; + const fm: SkillFrontmatter = {}; + for (const line of block.split(/\r?\n/)) { + const mm = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/.exec(line); + if (!mm) continue; + const key = mm[1]; + let val: string = mm[2].trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1).replace(/\\"/g, '"'); + } + switch (key) { + case "featured": { + const n = Number(val); + if (Number.isFinite(n)) fm.featured = n; + break; + } + case "recommended": { + const n = Number(val); + if (Number.isFinite(n)) fm.recommended = n; + break; + } + case "tags": { + const arr = /^\[(.*)\]$/.exec(val); + if (arr) { + fm.tags = arr[1] + .split(",") + .map((s) => s.trim().replace(/^["']|["']$/g, "")) + .map((s) => s.replace(/\\"/g, '"')) + .filter(Boolean); + } + break; + } + case "name": + case "zh_name": + case "en_name": + case "emoji": + case "description": + case "category": + case "scenario": + case "aspect_hint": + case "example_id": + case "example_name": + case "example_format": + case "example_tagline": + case "example_desc": + case "example_source_url": + case "example_source_label": + (fm as Record)[key] = val; + break; + } + } + return { fm, body: body.trim() }; +} + +function safeRead(p: string): string | undefined { + try { + return fs.readFileSync(p, "utf8"); + } catch { + return undefined; + } +} + +function fmToMeta(id: string, fm: SkillFrontmatter, hasHtml: boolean, hasMd: boolean): SkillMeta { + const meta: SkillMeta = { + id, + zhName: fm.zh_name ?? fm.name ?? id, + enName: fm.en_name ?? id, + emoji: fm.emoji ?? "✨", + description: fm.description ?? "", + category: fm.category ?? "other", + scenario: fm.scenario ?? "marketing", + aspectHint: fm.aspect_hint ?? "", + tags: Array.isArray(fm.tags) ? fm.tags : [], + }; + if (typeof fm.featured === "number") meta.featured = fm.featured; + if (typeof fm.recommended === "number") meta.recommended = fm.recommended; + if (fm.example_id || hasMd || hasHtml) { + meta.example = { + id: fm.example_id ?? `example-${id}`, + name: fm.example_name ?? `${meta.zhName} 示例`, + format: fm.example_format ?? "markdown", + tagline: fm.example_tagline ?? "", + desc: fm.example_desc ?? "", + hasHtml, + hasMd, + ...(fm.example_source_url + ? { + source: { + url: fm.example_source_url, + label: fm.example_source_label ?? fm.example_source_url, + }, + } + : {}), + }; + } + return meta; +} + +function isValidId(id: string): boolean { + return /^[a-z0-9][a-z0-9-]*$/i.test(id); +} + +export function listSkills(skillsDir: string): SkillMeta[] { + const out: SkillMeta[] = []; + let dirents: fs.Dirent[] = []; + try { + dirents = fs.readdirSync(skillsDir, { withFileTypes: true }); + } catch { + return out; + } + for (const ent of dirents) { + if (!ent.isDirectory()) continue; + const id = ent.name; + if (!isValidId(id)) continue; + const dir = path.join(skillsDir, id); + const raw = safeRead(path.join(dir, "SKILL.md")); + if (!raw) continue; + const { fm } = parseFrontmatter(raw); + const hasHtml = fs.existsSync(path.join(dir, "example.html")); + const hasMd = fs.existsSync(path.join(dir, "example.md")); + out.push(fmToMeta(id, fm, hasHtml, hasMd)); + } + return out; +} + +export function loadSkill(skillsDir: string, id: string): LoadedSkill | null { + if (!isValidId(id)) return null; + const dir = path.join(skillsDir, id); + const raw = safeRead(path.join(dir, "SKILL.md")); + if (!raw) return null; + const { fm, body } = parseFrontmatter(raw); + const exampleMd = safeRead(path.join(dir, "example.md")); + const exampleHtml = safeRead(path.join(dir, "example.html")); + const meta = fmToMeta(id, fm, !!exampleHtml, !!exampleMd); + return { ...meta, body, exampleMd, exampleHtml }; +} \ No newline at end of file diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..8624f18 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d37a468..bc2f9c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,18 @@ importers: specifier: ^4.22.1 version: 4.22.1 + cli: + devDependencies: + '@types/node': + specifier: ^20 + version: 20.19.40 + tsx: + specifier: ^4.22.1 + version: 4.22.1 + typescript: + specifier: ^5 + version: 5.9.3 + e2e: devDependencies: '@playwright/test': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dfeee6a..9086f33 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: + - cli - e2e - next From 9c231dd652e59611e1b4e71487917bbae24c889b Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Thu, 21 May 2026 18:35:49 +0800 Subject: [PATCH 02/16] fix(cli): fail hard on non-HTML output, flush status in non-TTY mode - extractHtml: return empty string instead of wrapping non-HTML in pre tag, so the CLI correctly surfaces agent errors (rate limits, auth failures) instead of silently saving a valid-looking HTML file around error text - createSpinner: in the non-TTY branch, still flush the final status message to stderr so CI/piped scripts can diagnose failures --- cli/src/extract-html.ts | 11 +---------- cli/src/index.ts | 4 +++- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/cli/src/extract-html.ts b/cli/src/extract-html.ts index 09cb75b..704ea72 100644 --- a/cli/src/extract-html.ts +++ b/cli/src/extract-html.ts @@ -33,14 +33,5 @@ export function extractHtml(streamed: string): string { return streamed; } - return `
${escape(
-    streamed,
-  )}
`; -} - -function escape(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">"); + return ""; } \ No newline at end of file diff --git a/cli/src/index.ts b/cli/src/index.ts index 5467b72..48d12ad 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -101,7 +101,9 @@ function createSpinner(msg: string) { return { tick: () => { chunkCount++; }, start, - stop: (_final?: string) => {}, + stop: (final?: string) => { + if (final !== undefined) process.stderr.write(`${final}\n`); + }, }; } From 57783b3a564441eb18e322b74a4c46911e0f543d Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Thu, 21 May 2026 20:03:31 +0800 Subject: [PATCH 03/16] fix(cli): robust error handling, multi-file support, overwrite prompt Agent exit-code & stderr (A): track done.code and stderr; if the agent exits non-zero, report the failure instead of silently saving a (possibly truncated) HTML file with exit 0. Format validation (B): reject unknown --format values with a list of supported formats (markdown, text, csv, json). Config write guard (C): catch filesystem errors in saveConfig() so disk- full/permission failures show a readable message instead of an uncaught exception. Overwrite prompt (D): ask before overwriting an existing output file in TTY mode; skip the prompt (auto-overwrite) when piped/CI. EPIPE handler (E): catch broken-pipe errors on stdout so piping to head(1) or early-closing consumers does not print a noisy stacktrace. -o/-d conflict (F): error when both --output and --output-dir are set. Multi-file support (G): accept multiple positional input files, process each sequentially, then summarise failures. --- cli/README.md | 3 + cli/src/config.ts | 8 +- cli/src/index.ts | 189 +++++++++++++++++++++++++++++++++++----------- 3 files changed, 156 insertions(+), 44 deletions(-) diff --git a/cli/README.md b/cli/README.md index 2b4ceee..f4efa31 100644 --- a/cli/README.md +++ b/cli/README.md @@ -50,6 +50,9 @@ html-anything config set-default-template doc-kami-parchment # 使用默认模板转换(自动保存为 article.html) html-anything convert article.md +# 批量转换多个文件 +html-anything convert file1.md file2.md file3.md -d ./dist + # 保存到指定文件 html-anything convert article.md -o output.html diff --git a/cli/src/config.ts b/cli/src/config.ts index 8644f96..550b6ee 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -31,7 +31,13 @@ export function loadConfig(): CliConfig { export function saveConfig(config: CliConfig): void { ensureConfigDir(); const merged = { ...loadConfig(), ...config }; - fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2), "utf-8"); + try { + fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2), "utf-8"); + } catch (err) { + throw new Error( + `Cannot write config to ${CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`, + ); + } } export function getConfigPath(): string { diff --git a/cli/src/index.ts b/cli/src/index.ts index 48d12ad..f1d3bd1 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import readline from "node:readline"; import { fileURLToPath } from "node:url"; import { loadSkill, listSkills, type SkillMeta, type LoadedSkill } from "./skills-loader.js"; import { detectAgents, type DetectedAgent } from "./agents-detect.js"; @@ -87,6 +88,7 @@ EXAMPLES: html-anything convert article.md -t doc-kami-parchment -o output.html html-anything convert article.md -t doc-kami-parchment -d ./dist html-anything convert article.md -a claude --model sonnet + html-anything convert file1.md file2.md file3.md -d ./dist cat article.md | html-anything convert html-anything config set-default-template resume-modern html-anything templates @@ -162,6 +164,23 @@ async function handleConvert(args: string[]): Promise { } } + if (flags.output && flags.outputDir) { + console.error("Error: --output (-o) and --output-dir (-d) cannot be used together."); + process.exit(1); + } + + if (flags.output && positional.length > 1) { + console.error("Error: --output (-o) cannot be used with multiple input files. Use --output-dir (-d) instead."); + process.exit(1); + } + + const VALID_FORMATS = ["markdown", "text", "csv", "json"]; + const format = flags.format ?? "markdown"; + if (!VALID_FORMATS.includes(format)) { + console.error(`Error: Unknown format "${format}". Supported: ${VALID_FORMATS.join(", ")}`); + process.exit(1); + } + const config = loadConfig(); const templateId = flags.template ?? config.defaultTemplate; @@ -195,45 +214,75 @@ async function handleConvert(args: string[]): Promise { process.exit(1); } - const format = flags.format ?? "markdown"; - let content: string; - let inputPath: string | null = null; + const model = flags.model ?? config.model; - if (positional.length > 0) { - inputPath = positional[0]; - try { - content = fs.readFileSync(inputPath, "utf-8"); - } catch (err) { - console.error(`Error: Cannot read input file "${inputPath}": ${err instanceof Error ? err.message : err}`); + const inputPaths = positional.length > 0 ? positional : []; + + if (inputPaths.length === 0) { + const content = await readStdin(); + if (!content.trim()) { + console.error("Error: No input provided. Pipe content via stdin or specify an input file."); process.exit(1); } + process.stdout.on("error", (err) => { + if ((err as NodeJS.ErrnoException).code === "EPIPE") process.exit(0); + }); + const ok = await convertOne({ inputPath: null, content, skill, agent, model, format, flags }); + if (!ok) process.exit(1); } else { - content = await readStdin(); - if (!content.trim()) { - console.error("Error: No input provided. Pipe content via stdin or specify an input file."); + let failed = 0; + for (const inputPath of inputPaths) { + let content: string; + try { + content = fs.readFileSync(inputPath, "utf-8"); + } catch (err) { + console.error(`Error: Cannot read "${inputPath}": ${err instanceof Error ? err.message : err}`); + failed++; + continue; + } + const ok = await convertOne({ inputPath, content, skill, agent, model, format, flags }); + if (!ok) failed++; + } + if (failed > 0) { + if (inputPaths.length > 1) { + console.error(`\n${failed}/${inputPaths.length} file(s) failed.`); + } process.exit(1); } } +} - const prompt = assemblePrompt({ body: skill.body, content, format }); +async function convertOne(opts: { + inputPath: string | null; + content: string; + skill: LoadedSkill; + agent: DetectedAgent; + model: string | undefined; + format: string; + flags: Record; +}): Promise { + const { inputPath, content, skill, agent: selectedAgent, model, format, flags } = opts; - const model = flags.model ?? config.model; + const prompt = assemblePrompt({ body: skill.body, content, format }); + const label = inputPath ? path.basename(inputPath) : "stdin"; console.error(`Template: ${skill.zhName} (${skill.id})`); - console.error(`Agent: ${agent.label} (${agent.id})`); + console.error(`Agent: ${selectedAgent.label} (${selectedAgent.id})`); if (model) console.error(`Model: ${model}`); + if (inputPath) console.error(`Input: ${label}`); console.error(""); const stream = invokeAgent({ - agent: agent.id, + agent: selectedAgent.id, prompt, model, }); const reader = stream.getReader(); let htmlAccum = ""; - let hasHtmlFromTool = false; - const spinner = createSpinner("Generating HTML..."); + const spinner = createSpinner(`Generating HTML for ${label}...`); + let stderrBuf = ""; + let exitCode: number | null = null; try { while (true) { @@ -248,23 +297,35 @@ async function handleConvert(args: string[]): Promise { break; case "html": htmlAccum = value.text; - hasHtmlFromTool = true; spinner.tick(); break; case "error": spinner.stop(`\x1b[31m✗\x1b[0m Error: ${value.message}`); - process.exit(1); - case "meta": - break; + return false; case "stderr": + stderrBuf += value.text; break; case "done": + exitCode = value.code; + break; + case "meta": + case "raw": + case "start": break; } } } catch (err) { spinner.stop(`\x1b[31m✗\x1b[0m Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); + return false; + } + + if (exitCode !== null && exitCode !== 0) { + const elapsed = ((Date.now() - spinner.start) / 1000).toFixed(1); + spinner.stop(`\x1b[31m✗\x1b[0m Agent exited with code ${exitCode} after ${elapsed}s`); + if (stderrBuf.trim()) { + console.error("Agent stderr:", stderrBuf.trim()); + } + return false; } const elapsed = ((Date.now() - spinner.start) / 1000).toFixed(1); @@ -274,36 +335,58 @@ async function handleConvert(args: string[]): Promise { if (!html) { console.error("Error: Agent did not produce valid HTML output."); - console.error("Raw output:\n", htmlAccum.slice(0, 500)); - process.exit(1); + if (htmlAccum) console.error("Raw output:\n", htmlAccum.slice(0, 500)); + return false; } + let outputPath: string | undefined; + if (flags.output) { - try { - fs.mkdirSync(path.dirname(path.resolve(flags.output)), { recursive: true }); - fs.writeFileSync(flags.output, html, "utf-8"); - console.error(`Saved to: ${flags.output}`); - } catch (err) { - console.error(`Error: Cannot write to "${flags.output}": ${err instanceof Error ? err.message : err}`); - process.exit(1); - } + outputPath = path.resolve(flags.output); } else if (inputPath) { const basename = path.basename(inputPath, path.extname(inputPath)); const outputDir = flags.outputDir || process.cwd(); - const outputPath = path.resolve(outputDir, `${basename}.html`); + outputPath = path.resolve(outputDir, `${basename}.html`); + } + + if (outputPath) { + if (fs.existsSync(outputPath)) { + const overwrite = await promptOverwrite(outputPath); + if (!overwrite) { + console.error(`Skipped: ${outputPath}`); + return true; + } + } + try { fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, html, "utf-8"); console.error(`Saved to: ${outputPath}`); + return true; } catch (err) { console.error(`Error: Cannot write to "${outputPath}": ${err instanceof Error ? err.message : err}`); - process.exit(1); + return false; } } else { process.stdout.write(html); + return true; } } +function promptOverwrite(filepath: string): Promise { + return new Promise((resolve) => { + if (!process.stdin.isTTY || !process.stderr.isTTY) { + resolve(true); + return; + } + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + rl.question(`\x1b[33m⚠\x1b[0m ${filepath} already exists. Overwrite? (y/N): `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +} + function readStdin(): Promise { return new Promise((resolve) => { if (process.stdin.isTTY) { @@ -399,8 +482,13 @@ function handleConfig(args: string[]): void { console.error(`Error: Unknown template "${val}"`); process.exit(1); } - saveConfig({ defaultTemplate: val }); - console.log(`Default template set to: ${val} (${skill.zhName})`); + try { + saveConfig({ defaultTemplate: val }); + console.log(`Default template set to: ${val} (${skill.zhName})`); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } break; } case "set-default-agent": { @@ -414,8 +502,13 @@ function handleConfig(args: string[]): void { console.error(`Error: Unknown agent "${val}"`); process.exit(1); } - saveConfig({ defaultAgent: val }); - console.log(`Default agent set to: ${val} (${agent.label})`); + try { + saveConfig({ defaultAgent: val }); + console.log(`Default agent set to: ${val} (${agent.label})`); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } break; } case "set-model": { @@ -423,13 +516,23 @@ function handleConfig(args: string[]): void { console.error("Error: Specify a model ID."); process.exit(1); } - saveConfig({ model: val }); - console.log(`Default model set to: ${val}`); + try { + saveConfig({ model: val }); + console.log(`Default model set to: ${val}`); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } break; } case "reset": { - saveConfig({ defaultTemplate: undefined, defaultAgent: undefined, model: undefined }); - console.log("Configuration reset."); + try { + saveConfig({ defaultTemplate: undefined, defaultAgent: undefined, model: undefined }); + console.log("Configuration reset."); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } break; } default: From 35b5cd76d29ec20aa4d333055339b1058f631a12 Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Thu, 21 May 2026 20:35:30 +0800 Subject: [PATCH 04/16] fix(cli): pre-scan batch outputs for basename collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When multiple input files would produce the same output basename (e.g. dir1/readme.md and dir2/readme.md both -> readme.html), the CLI now pre-scans before any work begins: 1. Collision detection — lists conflicting basenames and asks whether to resolve by preserving relative directory paths (dir1/readme.html). 2. Overwrite check — after resolving all output paths, checks whether any target files already exist and asks for confirmation before overwriting. 3. On N at any step, the CLI aborts with a clear error before any agent work starts. --- cli/src/index.ts | 134 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 117 insertions(+), 17 deletions(-) diff --git a/cli/src/index.ts b/cli/src/index.ts index f1d3bd1..e06c0c3 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -230,24 +230,97 @@ async function handleConvert(args: string[]): Promise { const ok = await convertOne({ inputPath: null, content, skill, agent, model, format, flags }); if (!ok) process.exit(1); } else { - let failed = 0; - for (const inputPath of inputPaths) { - let content: string; - try { - content = fs.readFileSync(inputPath, "utf-8"); - } catch (err) { - console.error(`Error: Cannot read "${inputPath}": ${err instanceof Error ? err.message : err}`); - failed++; - continue; + const outputDir = flags.outputDir || process.cwd(); + + if (inputPaths.length > 1) { + const flatBasenames = inputPaths.map((p) => path.basename(p, path.extname(p))); + const basenameCounts = new Map(); + for (let i = 0; i < flatBasenames.length; i++) { + const key = flatBasenames[i]; + if (!basenameCounts.has(key)) basenameCounts.set(key, []); + basenameCounts.get(key)!.push(inputPaths[i]); } - const ok = await convertOne({ inputPath, content, skill, agent, model, format, flags }); - if (!ok) failed++; - } - if (failed > 0) { - if (inputPaths.length > 1) { + const collisions = [...basenameCounts].filter(([, paths]) => paths.length > 1); + + if (collisions.length > 0) { + console.error(`\x1b[33m⚠\x1b[0m Multiple inputs would produce the same output basename:`); + for (const [basename, paths] of collisions) { + console.error(` ${basename}:`); + for (const p of paths) console.error(` → ${p}`); + } + const useRelative = await promptYesNo( + "\x1b[33m⚠\x1b[0m Save with relative directory paths (e.g. dir1/readme.html)? (y/N): ", + ); + if (!useRelative) { + console.error( + "Aborted. Rename your input files to use different basenames, or use --output (-o) for each file.", + ); + process.exit(1); + } + } + + const outputPlan = inputPaths.map((p) => ({ + inputPath: p, + outputPath: collisions.length > 0 + ? resolveCollisionOutput(p, outputDir) + : path.resolve(outputDir, `${path.basename(p, path.extname(p))}.html`), + })); + + const existingFiles = outputPlan.filter((p) => fs.existsSync(p.outputPath)); + if (existingFiles.length > 0) { + console.error(`\x1b[33m⚠\x1b[0m The following output files already exist:`); + for (const p of existingFiles) console.error(` ${p.outputPath}`); + const ok = await promptYesNo("\x1b[33m⚠\x1b[0m Overwrite? (y/N): "); + if (!ok) { + console.error("Aborted."); + process.exit(1); + } + } + + let failed = 0; + for (const plan of outputPlan) { + let content: string; + try { + content = fs.readFileSync(plan.inputPath, "utf-8"); + } catch (err) { + console.error(`Error: Cannot read "${plan.inputPath}": ${err instanceof Error ? err.message : err}`); + failed++; + continue; + } + const ok = await convertOne({ + inputPath: plan.inputPath, + content, + skill, + agent, + model, + format, + flags, + resolvedOutputPath: plan.outputPath, + }); + if (!ok) failed++; + } + if (failed > 0) { console.error(`\n${failed}/${inputPaths.length} file(s) failed.`); + process.exit(1); + } + } else { + let failed = 0; + for (const inputPath of inputPaths) { + let content: string; + try { + content = fs.readFileSync(inputPath, "utf-8"); + } catch (err) { + console.error(`Error: Cannot read "${inputPath}": ${err instanceof Error ? err.message : err}`); + failed++; + continue; + } + const ok = await convertOne({ inputPath, content, skill, agent, model, format, flags }); + if (!ok) failed++; + } + if (failed > 0) { + console.error(`\n${failed}/${inputPaths.length} file(s) failed.`); + process.exit(1); } - process.exit(1); } } } @@ -260,6 +333,7 @@ async function convertOne(opts: { model: string | undefined; format: string; flags: Record; + resolvedOutputPath?: string; }): Promise { const { inputPath, content, skill, agent: selectedAgent, model, format, flags } = opts; @@ -341,7 +415,9 @@ async function convertOne(opts: { let outputPath: string | undefined; - if (flags.output) { + if (opts.resolvedOutputPath) { + outputPath = opts.resolvedOutputPath; + } else if (flags.output) { outputPath = path.resolve(flags.output); } else if (inputPath) { const basename = path.basename(inputPath, path.extname(inputPath)); @@ -350,7 +426,7 @@ async function convertOne(opts: { } if (outputPath) { - if (fs.existsSync(outputPath)) { + if (!opts.resolvedOutputPath && fs.existsSync(outputPath)) { const overwrite = await promptOverwrite(outputPath); if (!overwrite) { console.error(`Skipped: ${outputPath}`); @@ -373,6 +449,30 @@ async function convertOne(opts: { } } +function resolveCollisionOutput(inputPath: string, outputDir: string): string { + const basename = path.basename(inputPath, path.extname(inputPath)); + const inputDir = path.dirname(inputPath); + const relativeDir = path.relative(process.cwd(), inputDir); + if (relativeDir && relativeDir !== ".") { + return path.resolve(outputDir, relativeDir, `${basename}.html`); + } + return path.resolve(outputDir, `${basename}.html`); +} + +function promptYesNo(question: string): Promise { + return new Promise((resolve) => { + if (!process.stdin.isTTY || !process.stderr.isTTY) { + resolve(false); + return; + } + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +} + function promptOverwrite(filepath: string): Promise { return new Promise((resolve) => { if (!process.stdin.isTTY || !process.stderr.isTTY) { From 3eef0ff1eda664f5fa13e6d57790a641784aca51 Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Thu, 21 May 2026 21:15:03 +0800 Subject: [PATCH 05/16] fix(cli): non-TTY batch overwrite + collision output path safety - Batch overwrite now skips the interactive prompt outside TTY (matching the single-file promptOverwrite auto-overwrite behaviour), so scripted CI runs don't abort when existing outputs are present. - resolveCollisionOutput now derives relative paths from the common ancestor of all colliding inputs (findCommonPath) instead of cwd, and strips '..' segments so outputs stay inside --output-dir, even when inputs live outside the current working directory. --- cli/src/index.ts | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/cli/src/index.ts b/cli/src/index.ts index e06c0c3..f3077ab 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -259,21 +259,26 @@ async function handleConvert(args: string[]): Promise { } } + const collisionPaths = collisions.flatMap(([, paths]) => paths); + const commonRoot = findCommonPath(collisionPaths); + const outputPlan = inputPaths.map((p) => ({ inputPath: p, outputPath: collisions.length > 0 - ? resolveCollisionOutput(p, outputDir) + ? resolveCollisionOutput(p, outputDir, commonRoot) : path.resolve(outputDir, `${path.basename(p, path.extname(p))}.html`), })); const existingFiles = outputPlan.filter((p) => fs.existsSync(p.outputPath)); if (existingFiles.length > 0) { - console.error(`\x1b[33m⚠\x1b[0m The following output files already exist:`); - for (const p of existingFiles) console.error(` ${p.outputPath}`); - const ok = await promptYesNo("\x1b[33m⚠\x1b[0m Overwrite? (y/N): "); - if (!ok) { - console.error("Aborted."); - process.exit(1); + if (process.stdin.isTTY && process.stderr.isTTY) { + console.error(`\x1b[33m⚠\x1b[0m The following output files already exist:`); + for (const p of existingFiles) console.error(` ${p.outputPath}`); + const ok = await promptYesNo("\x1b[33m⚠\x1b[0m Overwrite? (y/N): "); + if (!ok) { + console.error("Aborted."); + process.exit(1); + } } } @@ -449,11 +454,29 @@ async function convertOne(opts: { } } -function resolveCollisionOutput(inputPath: string, outputDir: string): string { +function findCommonPath(dirs: string[]): string { + if (dirs.length === 0) return ""; + const resolved = dirs.map((d) => path.resolve(d)); + const segments = resolved.map((d) => d.split(path.sep).filter(Boolean)); + const minLen = Math.min(...segments.map((s) => s.length)); + let common = 0; + for (let i = 0; i < minLen; i++) { + const seg = segments[0][i]; + if (segments.every((s) => s[i] === seg)) common++; + else break; + } + return segments[0].slice(0, common).join(path.sep) || path.sep; +} + +function resolveCollisionOutput(inputPath: string, outputDir: string, commonRoot: string): string { const basename = path.basename(inputPath, path.extname(inputPath)); - const inputDir = path.dirname(inputPath); - const relativeDir = path.relative(process.cwd(), inputDir); - if (relativeDir && relativeDir !== ".") { + const inputDir = path.resolve(path.dirname(inputPath)); + let relativeDir = path.relative(commonRoot, inputDir); + relativeDir = relativeDir + .split(path.sep) + .filter((s) => s !== ".." && s !== ".") + .join(path.sep); + if (relativeDir) { return path.resolve(outputDir, relativeDir, `${basename}.html`); } return path.resolve(outputDir, `${basename}.html`); From 0902719612cdc156376935e728999df373b4d3d3 Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Thu, 21 May 2026 21:46:00 +0800 Subject: [PATCH 06/16] fix(cli): deduplicate aider/deepseek close output + reject unsupported default agents - agents-invoke: aider/deepseek close path now enqueues stdoutBuf directly instead of running it through both parse() AND a raw enqueue, which was producing duplicate HTML (two blocks). - handleConfig set-default-agent: now rejects agents that are not installed (!available) or use an unsupported protocol (unsupported), with a clear error listing available supported alternatives. - findAgent: when resolving config.defaultAgent, now also filters out unsupported agents so a stale default (e.g. from manual config.json edit) automatically falls through to the next available agent. --- cli/src/agents-invoke.ts | 11 +-- cli/src/index.ts | 157 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 6 deletions(-) diff --git a/cli/src/agents-invoke.ts b/cli/src/agents-invoke.ts index 9a90c0f..3ff3816 100644 --- a/cli/src/agents-invoke.ts +++ b/cli/src/agents-invoke.ts @@ -587,13 +587,14 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { } } } else if (stdoutBuf) { - for (const part of parse(stdoutBuf)) { - if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); - else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); - else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); - } if (opts.agent === "aider" || opts.agent === "deepseek") { safeEnqueue({ type: "delta", text: stdoutBuf }); + } else { + for (const part of parse(stdoutBuf)) { + if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); + else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); + else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); + } } } safeEnqueue({ type: "done", code }); diff --git a/cli/src/index.ts b/cli/src/index.ts index f3077ab..a67b0f4 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -8,6 +8,7 @@ import { assemblePrompt } from "./prompt-assemble.js"; import { invokeAgent, type InvokeEvent } from "./agents-invoke.js"; import { extractHtml } from "./extract-html.js"; import { loadConfig, saveConfig, getConfigPath, type CliConfig } from "./config.js"; +import { matchTemplate, type MatchResult } from "./skills-matcher.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -51,7 +52,7 @@ function findAgent(agentId?: string): DetectedAgent | null { } const config = loadConfig(); if (config.defaultAgent) { - const found = agents.find((a) => a.id === config.defaultAgent && a.available); + const found = agents.find((a) => a.id === config.defaultAgent && a.available && !a.unsupported); if (found) return found; } return agents.find((a) => a.available && !a.unsupported) ?? null; @@ -73,6 +74,16 @@ COMMANDS: --model Model to use (optional) --format Input format: markdown, text, csv, json (default: markdown) + auto [input] Auto-detect best template and convert + input Input file, or use stdin if omitted + --agent, -a Agent ID (default: auto-detect) + --output, -o Output file path + --output-dir, -d Output directory for auto-saved files + --model Model to use (optional) + --format Input format: markdown, text, csv, json (default: markdown) + --force-ai Force AI summary for matching + --show-match-only Show match result, skip conversion + templates List all available templates agents List detected AI agents @@ -84,6 +95,11 @@ COMMANDS: config reset Reset all configuration EXAMPLES: + html-anything auto article.md + html-anything auto article.md -o output.html + html-anything auto article.md --show-match-only + html-anything auto article.md --force-ai + cat article.md | html-anything auto html-anything convert article.md html-anything convert article.md -t doc-kami-parchment -o output.html html-anything convert article.md -t doc-kami-parchment -d ./dist @@ -523,6 +539,128 @@ function readStdin(): Promise { }); } +async function handleAuto(args: string[]): Promise { + const flags: Record = {}; + let forceAi = false; + let showMatchOnly = false; + const positional: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--force-ai") { + forceAi = true; + } else if (arg === "--show-match-only") { + showMatchOnly = true; + } else if (arg === "--agent" || arg === "-a") { + flags.agent = args[++i] ?? ""; + } else if (arg === "--output" || arg === "-o") { + flags.output = args[++i] ?? ""; + } else if (arg === "--output-dir" || arg === "-d") { + flags.outputDir = args[++i] ?? ""; + } else if (arg === "--model") { + flags.model = args[++i] ?? ""; + } else if (arg === "--format") { + flags.format = args[++i] ?? ""; + } else if (arg === "--help" || arg === "-h") { + printHelp(); + return; + } else if (!arg.startsWith("-")) { + positional.push(arg); + } else { + console.error(`Unknown option: ${arg}`); + process.exit(1); + } + } + + if (flags.output && flags.outputDir) { + console.error("Error: --output (-o) and --output-dir (-d) cannot be used together."); + process.exit(1); + } + + if (showMatchOnly && flags.output) { + console.error("Error: --show-match-only cannot be used with --output (-o)."); + process.exit(1); + } + + const VALID_FORMATS = ["markdown", "text", "csv", "json"]; + const format = flags.format ?? "markdown"; + if (!VALID_FORMATS.includes(format)) { + console.error(`Error: Unknown format "${format}". Supported: ${VALID_FORMATS.join(", ")}`); + process.exit(1); + } + + const config = loadConfig(); + + const agent = findAgent(flags.agent); + if (!agent) { + const wantId = flags.agent ?? config.defaultAgent ?? "(auto-detect)"; + console.error(`Error: No available AI agent found${flags.agent ? ` for "${wantId}"` : ""}.`); + console.error("\nDetected agents:"); + for (const a of getAvailableAgents()) { + const status = a.available ? (a.unsupported ? "(unsupported)" : "✓") : "✗"; + console.error(` ${status} ${a.id} — ${a.label}`); + } + console.error("\nInstall one of the supported agents (e.g. 'claude', 'codex', 'gemini') and try again."); + process.exit(1); + } + + const model = flags.model ?? config.model; + + const inputPath = positional.length > 0 ? positional[0] : null; + let content: string; + if (inputPath) { + try { + content = fs.readFileSync(inputPath, "utf-8"); + } catch (err) { + console.error(`Error: Cannot read "${inputPath}": ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + } else { + content = await readStdin(); + if (!content.trim()) { + console.error("Error: No input provided. Pipe content via stdin or specify an input file."); + process.exit(1); + } + } + + const templates = getAvailableTemplates(); + if (templates.length === 0) { + console.error("Error: No templates found."); + process.exit(1); + } + + const label = inputPath ? path.basename(inputPath) : "stdin"; + console.error(`Matching template for: ${label}`); + console.error(`Agent: ${agent.label} (${agent.id})`); + if (model) console.error(`Model: ${model}`); + console.error(""); + + const result = await matchTemplate( + content, + templates, + SKILLS_DIR, + agent.id, + forceAi, + ); + + console.error(`Matched: ${result.zhName} (${result.templateId})`); + console.error(`Confidence: ${result.confidence}/10`); + console.error(`Reason: ${result.reason}`); + + if (showMatchOnly) return; + + console.error(""); + + const skill = getTemplate(result.templateId); + if (!skill) { + console.error(`Error: Unknown template "${result.templateId}"`); + process.exit(1); + } + + const ok = await convertOne({ inputPath, content, skill, agent, model, format, flags }); + if (!ok) process.exit(1); +} + function handleTemplates(): void { const templates = getAvailableTemplates(); @@ -625,6 +763,20 @@ function handleConfig(args: string[]): void { console.error(`Error: Unknown agent "${val}"`); process.exit(1); } + if (!agent.available) { + console.error(`Error: Agent "${val}" (${agent.label}) is not installed.`); + process.exit(1); + } + if (agent.unsupported) { + console.error( + `Error: Agent "${val}" (${agent.label}) uses an unsupported protocol.`, + ); + console.error("Available supported agents:"); + for (const a of agents.filter((a) => a.available && !a.unsupported)) { + console.error(` ${a.id} — ${a.label}`); + } + process.exit(1); + } try { saveConfig({ defaultAgent: val }); console.log(`Default agent set to: ${val} (${agent.label})`); @@ -678,6 +830,9 @@ export async function main(args: string[]): Promise { case "convert": await handleConvert(rest); break; + case "auto": + await handleAuto(rest); + break; case "templates": handleTemplates(); break; From d8b9c8f6d5b652a3e1cdc1395307046876c51a5c Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Thu, 21 May 2026 21:49:24 +0800 Subject: [PATCH 07/16] Revert "fix(cli): deduplicate aider/deepseek close output + reject unsupported default agents" This reverts commit 19636bcc334c3d941c7008724a1da303d8ccda3c. --- cli/src/agents-invoke.ts | 11 ++- cli/src/index.ts | 157 +-------------------------------------- 2 files changed, 6 insertions(+), 162 deletions(-) diff --git a/cli/src/agents-invoke.ts b/cli/src/agents-invoke.ts index 3ff3816..9a90c0f 100644 --- a/cli/src/agents-invoke.ts +++ b/cli/src/agents-invoke.ts @@ -587,14 +587,13 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { } } } else if (stdoutBuf) { + for (const part of parse(stdoutBuf)) { + if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); + else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); + else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); + } if (opts.agent === "aider" || opts.agent === "deepseek") { safeEnqueue({ type: "delta", text: stdoutBuf }); - } else { - for (const part of parse(stdoutBuf)) { - if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); - else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); - else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); - } } } safeEnqueue({ type: "done", code }); diff --git a/cli/src/index.ts b/cli/src/index.ts index a67b0f4..f3077ab 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -8,7 +8,6 @@ import { assemblePrompt } from "./prompt-assemble.js"; import { invokeAgent, type InvokeEvent } from "./agents-invoke.js"; import { extractHtml } from "./extract-html.js"; import { loadConfig, saveConfig, getConfigPath, type CliConfig } from "./config.js"; -import { matchTemplate, type MatchResult } from "./skills-matcher.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -52,7 +51,7 @@ function findAgent(agentId?: string): DetectedAgent | null { } const config = loadConfig(); if (config.defaultAgent) { - const found = agents.find((a) => a.id === config.defaultAgent && a.available && !a.unsupported); + const found = agents.find((a) => a.id === config.defaultAgent && a.available); if (found) return found; } return agents.find((a) => a.available && !a.unsupported) ?? null; @@ -74,16 +73,6 @@ COMMANDS: --model Model to use (optional) --format Input format: markdown, text, csv, json (default: markdown) - auto [input] Auto-detect best template and convert - input Input file, or use stdin if omitted - --agent, -a Agent ID (default: auto-detect) - --output, -o Output file path - --output-dir, -d Output directory for auto-saved files - --model Model to use (optional) - --format Input format: markdown, text, csv, json (default: markdown) - --force-ai Force AI summary for matching - --show-match-only Show match result, skip conversion - templates List all available templates agents List detected AI agents @@ -95,11 +84,6 @@ COMMANDS: config reset Reset all configuration EXAMPLES: - html-anything auto article.md - html-anything auto article.md -o output.html - html-anything auto article.md --show-match-only - html-anything auto article.md --force-ai - cat article.md | html-anything auto html-anything convert article.md html-anything convert article.md -t doc-kami-parchment -o output.html html-anything convert article.md -t doc-kami-parchment -d ./dist @@ -539,128 +523,6 @@ function readStdin(): Promise { }); } -async function handleAuto(args: string[]): Promise { - const flags: Record = {}; - let forceAi = false; - let showMatchOnly = false; - const positional: string[] = []; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "--force-ai") { - forceAi = true; - } else if (arg === "--show-match-only") { - showMatchOnly = true; - } else if (arg === "--agent" || arg === "-a") { - flags.agent = args[++i] ?? ""; - } else if (arg === "--output" || arg === "-o") { - flags.output = args[++i] ?? ""; - } else if (arg === "--output-dir" || arg === "-d") { - flags.outputDir = args[++i] ?? ""; - } else if (arg === "--model") { - flags.model = args[++i] ?? ""; - } else if (arg === "--format") { - flags.format = args[++i] ?? ""; - } else if (arg === "--help" || arg === "-h") { - printHelp(); - return; - } else if (!arg.startsWith("-")) { - positional.push(arg); - } else { - console.error(`Unknown option: ${arg}`); - process.exit(1); - } - } - - if (flags.output && flags.outputDir) { - console.error("Error: --output (-o) and --output-dir (-d) cannot be used together."); - process.exit(1); - } - - if (showMatchOnly && flags.output) { - console.error("Error: --show-match-only cannot be used with --output (-o)."); - process.exit(1); - } - - const VALID_FORMATS = ["markdown", "text", "csv", "json"]; - const format = flags.format ?? "markdown"; - if (!VALID_FORMATS.includes(format)) { - console.error(`Error: Unknown format "${format}". Supported: ${VALID_FORMATS.join(", ")}`); - process.exit(1); - } - - const config = loadConfig(); - - const agent = findAgent(flags.agent); - if (!agent) { - const wantId = flags.agent ?? config.defaultAgent ?? "(auto-detect)"; - console.error(`Error: No available AI agent found${flags.agent ? ` for "${wantId}"` : ""}.`); - console.error("\nDetected agents:"); - for (const a of getAvailableAgents()) { - const status = a.available ? (a.unsupported ? "(unsupported)" : "✓") : "✗"; - console.error(` ${status} ${a.id} — ${a.label}`); - } - console.error("\nInstall one of the supported agents (e.g. 'claude', 'codex', 'gemini') and try again."); - process.exit(1); - } - - const model = flags.model ?? config.model; - - const inputPath = positional.length > 0 ? positional[0] : null; - let content: string; - if (inputPath) { - try { - content = fs.readFileSync(inputPath, "utf-8"); - } catch (err) { - console.error(`Error: Cannot read "${inputPath}": ${err instanceof Error ? err.message : err}`); - process.exit(1); - } - } else { - content = await readStdin(); - if (!content.trim()) { - console.error("Error: No input provided. Pipe content via stdin or specify an input file."); - process.exit(1); - } - } - - const templates = getAvailableTemplates(); - if (templates.length === 0) { - console.error("Error: No templates found."); - process.exit(1); - } - - const label = inputPath ? path.basename(inputPath) : "stdin"; - console.error(`Matching template for: ${label}`); - console.error(`Agent: ${agent.label} (${agent.id})`); - if (model) console.error(`Model: ${model}`); - console.error(""); - - const result = await matchTemplate( - content, - templates, - SKILLS_DIR, - agent.id, - forceAi, - ); - - console.error(`Matched: ${result.zhName} (${result.templateId})`); - console.error(`Confidence: ${result.confidence}/10`); - console.error(`Reason: ${result.reason}`); - - if (showMatchOnly) return; - - console.error(""); - - const skill = getTemplate(result.templateId); - if (!skill) { - console.error(`Error: Unknown template "${result.templateId}"`); - process.exit(1); - } - - const ok = await convertOne({ inputPath, content, skill, agent, model, format, flags }); - if (!ok) process.exit(1); -} - function handleTemplates(): void { const templates = getAvailableTemplates(); @@ -763,20 +625,6 @@ function handleConfig(args: string[]): void { console.error(`Error: Unknown agent "${val}"`); process.exit(1); } - if (!agent.available) { - console.error(`Error: Agent "${val}" (${agent.label}) is not installed.`); - process.exit(1); - } - if (agent.unsupported) { - console.error( - `Error: Agent "${val}" (${agent.label}) uses an unsupported protocol.`, - ); - console.error("Available supported agents:"); - for (const a of agents.filter((a) => a.available && !a.unsupported)) { - console.error(` ${a.id} — ${a.label}`); - } - process.exit(1); - } try { saveConfig({ defaultAgent: val }); console.log(`Default agent set to: ${val} (${agent.label})`); @@ -830,9 +678,6 @@ export async function main(args: string[]): Promise { case "convert": await handleConvert(rest); break; - case "auto": - await handleAuto(rest); - break; case "templates": handleTemplates(); break; From 889878eece03f5a531e2688f275e5f544ee38dca Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Thu, 21 May 2026 22:02:15 +0800 Subject: [PATCH 08/16] fix(cli): deduplicate aider/deepseek output + reject unsupported default agents - agents-invoke: aider/deepseek close path now enqueues stdoutBuf directly instead of running it through both parse() AND a raw enqueue, which was producing duplicate blocks. - findAgent: when resolving config.defaultAgent, now also filters out unsupported agents so a stale default (e.g. from manual config.json edit) automatically falls through to the next available agent. - handleConfig set-default-agent: now rejects agents that are not installed or use an unsupported protocol, with a clear error listing available supported alternatives. --- cli/src/agents-invoke.ts | 11 ++++++----- cli/src/index.ts | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/cli/src/agents-invoke.ts b/cli/src/agents-invoke.ts index 9a90c0f..3ff3816 100644 --- a/cli/src/agents-invoke.ts +++ b/cli/src/agents-invoke.ts @@ -587,13 +587,14 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { } } } else if (stdoutBuf) { - for (const part of parse(stdoutBuf)) { - if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); - else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); - else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); - } if (opts.agent === "aider" || opts.agent === "deepseek") { safeEnqueue({ type: "delta", text: stdoutBuf }); + } else { + for (const part of parse(stdoutBuf)) { + if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); + else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); + else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); + } } } safeEnqueue({ type: "done", code }); diff --git a/cli/src/index.ts b/cli/src/index.ts index f3077ab..e0cb7e2 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -51,7 +51,7 @@ function findAgent(agentId?: string): DetectedAgent | null { } const config = loadConfig(); if (config.defaultAgent) { - const found = agents.find((a) => a.id === config.defaultAgent && a.available); + const found = agents.find((a) => a.id === config.defaultAgent && a.available && !a.unsupported); if (found) return found; } return agents.find((a) => a.available && !a.unsupported) ?? null; @@ -625,6 +625,20 @@ function handleConfig(args: string[]): void { console.error(`Error: Unknown agent "${val}"`); process.exit(1); } + if (!agent.available) { + console.error(`Error: Agent "${val}" (${agent.label}) is not installed.`); + process.exit(1); + } + if (agent.unsupported) { + console.error( + `Error: Agent "${val}" (${agent.label}) uses an unsupported protocol.`, + ); + console.error("Available supported agents:"); + for (const a of agents.filter((a) => a.available && !a.unsupported)) { + console.error(` ${a.id} — ${a.label}`); + } + process.exit(1); + } try { saveConfig({ defaultAgent: val }); console.log(`Default agent set to: ${val} (${agent.label})`); From b78161dd2c6c6388eead21a34c4454ce8286b1d3 Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Thu, 21 May 2026 22:36:20 +0800 Subject: [PATCH 09/16] fix(cli): resolve *_BIN env overrides via PATH for detection detectAgents() previously only accepted *_BIN overrides as absolute paths (existsSync). Relative command names like GEMINI_BIN=fake-claude were dropped even though invocation (resolveBinForAgent) can find them on PATH. Now falls back to resolveOnPath() when existsSync fails, so detection and config flows match the actual invoke behaviour. --- cli/src/agents-detect.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cli/src/agents-detect.ts b/cli/src/agents-detect.ts index 1b063b6..7218cd5 100644 --- a/cli/src/agents-detect.ts +++ b/cli/src/agents-detect.ts @@ -353,8 +353,14 @@ export function detectAgents(): DetectedAgent[] { unsupported: unsupported || undefined, }; const override = a.envOverride ? process.env[a.envOverride] : undefined; - if (override && existsSync(override)) { - return { ...base, available: true, path: override, resolvedBin: a.bin }; + if (override) { + if (existsSync(override)) { + return { ...base, available: true, path: override, resolvedBin: a.bin }; + } + const p = resolveOnPath(override); + if (p) { + return { ...base, available: true, path: p, resolvedBin: override }; + } } const candidates = [a.bin, ...(a.fallbackBins ?? [])]; for (const c of candidates) { From 978774bb6851d9ef4f6988cdbd031163de22d82f Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Thu, 21 May 2026 23:00:50 +0800 Subject: [PATCH 10/16] fix: auto-enable relative paths for basename collisions in non-TTY mode --- cli/src/index.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/cli/src/index.ts b/cli/src/index.ts index e0cb7e2..ee41445 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -242,20 +242,25 @@ async function handleConvert(args: string[]): Promise { } const collisions = [...basenameCounts].filter(([, paths]) => paths.length > 1); + let useRelative = collisions.length > 0; if (collisions.length > 0) { console.error(`\x1b[33m⚠\x1b[0m Multiple inputs would produce the same output basename:`); for (const [basename, paths] of collisions) { console.error(` ${basename}:`); for (const p of paths) console.error(` → ${p}`); } - const useRelative = await promptYesNo( - "\x1b[33m⚠\x1b[0m Save with relative directory paths (e.g. dir1/readme.html)? (y/N): ", - ); - if (!useRelative) { - console.error( - "Aborted. Rename your input files to use different basenames, or use --output (-o) for each file.", + if (process.stdin.isTTY && process.stderr.isTTY) { + useRelative = await promptYesNo( + "\x1b[33m⚠\x1b[0m Save with relative directory paths (e.g. dir1/readme.html)? (y/N): ", ); - process.exit(1); + if (!useRelative) { + console.error( + "Aborted. Rename your input files to use different basenames, or use --output (-o) for each file.", + ); + process.exit(1); + } + } else { + console.error(`\x1b[33m⚠\x1b[0m Auto-enabling relative directory paths (non-interactive mode).`); } } @@ -264,7 +269,7 @@ async function handleConvert(args: string[]): Promise { const outputPlan = inputPaths.map((p) => ({ inputPath: p, - outputPath: collisions.length > 0 + outputPath: useRelative ? resolveCollisionOutput(p, outputDir, commonRoot) : path.resolve(outputDir, `${path.basename(p, path.extname(p))}.html`), })); From 3545050599dc3b186c1ec17c99d10be6144a6044 Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Fri, 22 May 2026 09:47:11 +0800 Subject: [PATCH 11/16] feat(cli): add comprehensive test framework (89 tests across 6 suites) Based on all reviewer feedback across 10 rounds, added a complete regression test suite covering every reported failure path: - extract-html.test.ts (9): non-HTML content returns empty, no scaffold wrapping - prompt.test.ts (11): TTY/non-TTY behavior for promptYesNo & promptOverwrite - collision-resolve.test.ts (8): findCommonPath & resolveCollisionOutput edge cases - agents-detect.test.ts (20): *_BIN env overrides, PATH resolution, unsupported protocols - agents-invoke.test.ts (19): DeepSeek/Aider close path no double-enqueue, exit code propagation - index.test.ts (22): param validation, config set-default-agent guards, convert integration Refactored for testability: - Extracted collision-resolve.ts (findCommonPath + resolveCollisionOutput) - Extracted prompt.ts (promptYesNo + promptOverwrite) All 89 tests pass. Typecheck and build clean. --- cli/package.json | 7 +- cli/src/__tests__/agents-detect.test.ts | 317 ++++++++++++++ cli/src/__tests__/agents-invoke.test.ts | 439 +++++++++++++++++++ cli/src/__tests__/collision-resolve.test.ts | 48 +++ cli/src/__tests__/extract-html.test.ts | 45 ++ cli/src/__tests__/index.test.ts | 446 ++++++++++++++++++++ cli/src/__tests__/prompt.test.ts | 138 ++++++ cli/src/collision-resolve.ts | 29 ++ cli/src/index.ts | 59 +-- cli/src/prompt.ts | 29 ++ cli/vitest.config.ts | 7 + pnpm-lock.yaml | 3 + 12 files changed, 1508 insertions(+), 59 deletions(-) create mode 100644 cli/src/__tests__/agents-detect.test.ts create mode 100644 cli/src/__tests__/agents-invoke.test.ts create mode 100644 cli/src/__tests__/collision-resolve.test.ts create mode 100644 cli/src/__tests__/extract-html.test.ts create mode 100644 cli/src/__tests__/index.test.ts create mode 100644 cli/src/__tests__/prompt.test.ts create mode 100644 cli/src/collision-resolve.ts create mode 100644 cli/src/prompt.ts create mode 100644 cli/vitest.config.ts diff --git a/cli/package.json b/cli/package.json index e0950fc..4a5eae3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -10,12 +10,15 @@ "scripts": { "build": "tsc", "dev": "tsx src/index.ts", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": {}, "devDependencies": { "@types/node": "^20", "tsx": "^4.22.1", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.6" } } \ No newline at end of file diff --git a/cli/src/__tests__/agents-detect.test.ts b/cli/src/__tests__/agents-detect.test.ts new file mode 100644 index 0000000..f42c0d4 --- /dev/null +++ b/cli/src/__tests__/agents-detect.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { existsSyncMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn((_path?: string) => false), +})); + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { ...actual, existsSync: existsSyncMock }; +}); + +import { detectAgents } from "../agents-detect.js"; + +function findAgent( + agents: ReturnType, + id: string, +) { + const agent = agents.find((a) => a.id === id); + if (!agent) throw new Error(`Agent with id "${id}" not found`); + return agent; +} + +beforeEach(() => { + existsSyncMock.mockReset(); + existsSyncMock.mockReturnValue(false); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("detectAgents", () => { + describe("*_BIN env override with absolute path that exists", () => { + it("finds claude via absolute CLAUDE_BIN path", () => { + vi.stubEnv("CLAUDE_BIN", "/usr/local/bin/claude"); + existsSyncMock.mockImplementation( + (p) => p === "/usr/local/bin/claude", + ); + + const agents = detectAgents(); + const claude = findAgent(agents, "claude"); + + expect(claude.available).toBe(true); + expect(claude.path).toBe("/usr/local/bin/claude"); + expect(claude.resolvedBin).toBe("claude"); + expect(claude.protocol).toBe("stdin"); + expect(claude.unsupported).toBeUndefined(); + }); + }); + + describe("*_BIN with command name resolved on PATH", () => { + it("finds gemini via GEMINI_BIN command name on PATH", () => { + vi.stubEnv("GEMINI_BIN", "fake-gemini"); + existsSyncMock.mockImplementation((p) => { + if (p === "/usr/local/bin/fake-gemini") return true; + return false; + }); + + const agents = detectAgents(); + const gemini = findAgent(agents, "gemini"); + + expect(gemini.available).toBe(true); + expect(gemini.resolvedBin).toBe("fake-gemini"); + expect(gemini.path).toBe("/usr/local/bin/fake-gemini"); + }); + }); + + describe("*_BIN pointing to non-existent path", () => { + it("returns unavailable when CLAUDE_BIN path does not exist", () => { + vi.stubEnv("CLAUDE_BIN", "/nonexistent/claude"); + existsSyncMock.mockReturnValue(false); + + const agents = detectAgents(); + const claude = findAgent(agents, "claude"); + + expect(claude.available).toBe(false); + expect(claude.path).toBeUndefined(); + expect(claude.resolvedBin).toBeUndefined(); + }); + }); + + describe("no env override, binary found on PATH", () => { + it("detects claude when binary is on PATH without env override", () => { + existsSyncMock.mockImplementation((p) => { + if (p === "/usr/local/bin/claude") return true; + return false; + }); + + const agents = detectAgents(); + const claude = findAgent(agents, "claude"); + + expect(claude.available).toBe(true); + expect(claude.path).toBe("/usr/local/bin/claude"); + expect(claude.resolvedBin).toBe("claude"); + }); + + it("detects aider which has no envOverride via bin on PATH", () => { + existsSyncMock.mockImplementation((p) => { + if (p === "/usr/local/bin/aider") return true; + return false; + }); + + const agents = detectAgents(); + const aider = findAgent(agents, "aider"); + + expect(aider.available).toBe(true); + expect(aider.path).toBe("/usr/local/bin/aider"); + expect(aider.resolvedBin).toBe("aider"); + }); + + it("detects opencode via fallbackBins when primary bin not found", () => { + existsSyncMock.mockImplementation((p) => { + if (p === "/usr/local/bin/opencode") return true; + return false; + }); + + const agents = detectAgents(); + const opencode = findAgent(agents, "opencode"); + + expect(opencode.available).toBe(true); + expect(opencode.path).toBe("/usr/local/bin/opencode"); + expect(opencode.resolvedBin).toBe("opencode"); + }); + + it("returns unavailable when no binary is on PATH and no env override", () => { + existsSyncMock.mockReturnValue(false); + + const agents = detectAgents(); + const claude = findAgent(agents, "claude"); + + expect(claude.available).toBe(false); + }); + }); + + describe("unsupported protocol agents", () => { + it("marks hermes with acp protocol as unsupported even when found", () => { + existsSyncMock.mockImplementation((p) => { + if (p === "/usr/local/bin/hermes") return true; + return false; + }); + + const agents = detectAgents(); + const hermes = findAgent(agents, "hermes"); + + expect(hermes.available).toBe(true); + expect(hermes.protocol).toBe("acp"); + expect(hermes.unsupported).toBe(true); + }); + + it("marks pi with pi-rpc protocol as unsupported even when found", () => { + existsSyncMock.mockImplementation((p) => { + if (p === "/usr/local/bin/pi") return true; + return false; + }); + + const agents = detectAgents(); + const pi = findAgent(agents, "pi"); + + expect(pi.available).toBe(true); + expect(pi.protocol).toBe("pi-rpc"); + expect(pi.unsupported).toBe(true); + }); + + it("marks unsupported as undefined for stdin protocol agents", () => { + existsSyncMock.mockImplementation((p) => { + if (p === "/usr/local/bin/claude") return true; + return false; + }); + + const agents = detectAgents(); + const claude = findAgent(agents, "claude"); + + expect(claude.protocol).toBe("stdin"); + expect(claude.unsupported).toBeUndefined(); + }); + + it("marks kimi with acp protocol as unsupported even when not found", () => { + existsSyncMock.mockReturnValue(false); + + const agents = detectAgents(); + const kimi = findAgent(agents, "kimi"); + + expect(kimi.available).toBe(false); + expect(kimi.protocol).toBe("acp"); + expect(kimi.unsupported).toBe(true); + }); + }); + + describe("agent protocol assignment", () => { + it("defaults protocol to stdin for agents without explicit protocol", () => { + existsSyncMock.mockImplementation((p) => { + if (p === "/usr/local/bin/codex") return true; + return false; + }); + + const agents = detectAgents(); + const codex = findAgent(agents, "codex"); + + expect(codex.protocol).toBe("stdin"); + }); + + it("preserves explicit protocol from agent definition (argv)", () => { + existsSyncMock.mockImplementation((p) => { + if (p === "/usr/local/bin/deepseek") return true; + return false; + }); + + const agents = detectAgents(); + const deepseek = findAgent(agents, "deepseek"); + + expect(deepseek.protocol).toBe("argv"); + }); + + it("preserves explicit protocol from agent definition (argv-message)", () => { + existsSyncMock.mockImplementation((p) => { + if (p === "/usr/local/bin/openclaw") return true; + return false; + }); + + const agents = detectAgents(); + const openclaw = findAgent(agents, "openclaw"); + + expect(openclaw.protocol).toBe("argv-message"); + }); + }); + + describe("env override precedence", () => { + it("prefers env override over PATH binary", () => { + vi.stubEnv("CLAUDE_BIN", "/custom/path/claude"); + existsSyncMock.mockImplementation((p) => { + if (p === "/custom/path/claude") return true; + if (p === "/usr/local/bin/claude") return true; + return false; + }); + + const agents = detectAgents(); + const claude = findAgent(agents, "claude"); + + expect(claude.available).toBe(true); + expect(claude.path).toBe("/custom/path/claude"); + expect(claude.resolvedBin).toBe("claude"); + }); + + it("falls back to PATH when env override is a command name that exists on PATH", () => { + vi.stubEnv("CLAUDE_BIN", "my-custom-claude"); + existsSyncMock.mockImplementation((p) => { + if (p === "/usr/local/bin/my-custom-claude") return true; + return false; + }); + + const agents = detectAgents(); + const claude = findAgent(agents, "claude"); + + expect(claude.available).toBe(true); + expect(claude.path).toBe("/usr/local/bin/my-custom-claude"); + expect(claude.resolvedBin).toBe("my-custom-claude"); + }); + }); + + describe("returned agent shape", () => { + it("returns all agents from AGENTS array", () => { + existsSyncMock.mockReturnValue(false); + + const agents = detectAgents(); + + expect(agents.length).toBeGreaterThanOrEqual(10); + }); + + it("each agent includes id, label, vendor, available, protocol, and models", () => { + existsSyncMock.mockReturnValue(false); + + const agents = detectAgents(); + + for (const agent of agents) { + expect(agent).toHaveProperty("id"); + expect(typeof agent.id).toBe("string"); + expect(agent).toHaveProperty("label"); + expect(typeof agent.label).toBe("string"); + expect(agent).toHaveProperty("vendor"); + expect(typeof agent.vendor).toBe("string"); + expect(agent).toHaveProperty("available"); + expect(typeof agent.available).toBe("boolean"); + expect(agent).toHaveProperty("protocol"); + expect(agent).toHaveProperty("models"); + expect(Array.isArray(agent.models)).toBe(true); + } + }); + + it("each available agent has path and resolvedBin", () => { + existsSyncMock.mockImplementation((p) => { + if (p === "/usr/local/bin/claude") return true; + if (p === "/usr/local/bin/aider") return true; + return false; + }); + + const agents = detectAgents(); + const availableAgents = agents.filter((a) => a.available); + + for (const agent of availableAgents) { + expect(agent).toHaveProperty("path"); + expect(typeof agent.path).toBe("string"); + expect(agent).toHaveProperty("resolvedBin"); + expect(typeof agent.resolvedBin).toBe("string"); + } + }); + + it("models array is non-empty for each agent", () => { + existsSyncMock.mockReturnValue(false); + + const agents = detectAgents(); + + for (const agent of agents) { + expect(agent.models.length).toBeGreaterThan(0); + } + }); + }); +}); diff --git a/cli/src/__tests__/agents-invoke.test.ts b/cli/src/__tests__/agents-invoke.test.ts new file mode 100644 index 0000000..b73f902 --- /dev/null +++ b/cli/src/__tests__/agents-invoke.test.ts @@ -0,0 +1,439 @@ +import { vi, describe, it, expect, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; +import { PassThrough, Writable } from "node:stream"; + +const mockSpawn = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", () => ({ + spawn: mockSpawn, +})); + +import { invokeAgent, type InvokeEvent } from "../agents-invoke.js"; + +function makeFakeChild() { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const stdin = new Writable({ + write(_chunk: unknown, _enc: unknown, cb: () => void) { + cb(); + }, + }); + + const child = Object.assign(new EventEmitter(), { + stdin, + stdout, + stderr, + pid: 99999, + }); + + return { child, stdout, stderr, stdin }; +} + +async function collectStream( + stream: ReadableStream, +): Promise { + const events: InvokeEvent[] = []; + const reader = stream.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) events.push(value); + } + return events; +} + +async function driveInvoke( + opts: Parameters[0], + stdoutContent: string | null, + exitCode: number | null = 0, +): Promise { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent(opts); + + await new Promise((r) => setTimeout(r, 0)); + + const eventsPromise = collectStream(stream); + + if (stdoutContent != null) { + stdout.write(stdoutContent); + } + stdout.end(); + + await new Promise((r) => setImmediate(r)); + child.emit("close", exitCode); + + return eventsPromise; +} + +const BIN_OVERRIDE = "/bin/sh"; + +describe("invokeAgent", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("error cases", () => { + it("returns error stream for unknown agent", async () => { + const stream = invokeAgent({ + agent: "nonexistent", + prompt: "test", + binOverride: BIN_OVERRIDE, + }); + + const events = await collectStream(stream); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("unknown agent"), + }); + }); + + it("returns error stream when binOverride points to missing file", async () => { + const stream = invokeAgent({ + agent: "claude", + prompt: "test", + binOverride: "/nonexistent/path/to/bin", + }); + + const events = await collectStream(stream); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("does not exist"), + }); + }); + + it("returns error stream for unsupported (acp) agent protocol", async () => { + const stream = invokeAgent({ + agent: "hermes", + prompt: "test", + binOverride: BIN_OVERRIDE, + }); + + const events = await collectStream(stream); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("not yet wired up"), + }); + }); + }); + + describe("close-path: deepseek and aider agents", () => { + it("deepseek: enqueues remaining stdoutBuf as single delta on close (HTML, no trailing newline)", async () => { + const html = "hello"; + + const events = await driveInvoke( + { agent: "deepseek", prompt: "make a page", binOverride: BIN_OVERRIDE }, + html, + 0, + ); + + const deltas = events.filter((e) => e.type === "delta"); + const done = events.filter((e) => e.type === "done"); + + expect(deltas).toHaveLength(1); + expect(deltas[0]).toMatchObject({ type: "delta", text: html }); + expect(done).toHaveLength(1); + expect(done[0]).toMatchObject({ type: "done", code: 0 }); + + const start = events.filter((e) => e.type === "start"); + expect(start).toHaveLength(1); + expect(events).toHaveLength(3); + }); + + it("deepseek: produces only ONE delta for partial line after complete lines", async () => { + const content = "line1\nline2"; + + const events = await driveInvoke( + { agent: "deepseek", prompt: "make a page", binOverride: BIN_OVERRIDE }, + content, + 0, + ); + + const deltas = events.filter((e) => e.type === "delta"); + const done = events.filter((e) => e.type === "done"); + + expect(deltas).toHaveLength(2); + expect(deltas[0]).toMatchObject({ type: "delta", text: "line1\n" }); + expect(deltas[1]).toMatchObject({ type: "delta", text: "line2" }); + expect(done).toHaveLength(1); + expect(done[0]).toMatchObject({ type: "done", code: 0 }); + }); + + it("deepseek: no residual on close when line ends with newline (no double-enqueue)", async () => { + const content = "hello\n"; + + const events = await driveInvoke( + { agent: "deepseek", prompt: "make a page", binOverride: BIN_OVERRIDE }, + content, + 0, + ); + + const deltas = events.filter((e) => e.type === "delta"); + const done = events.filter((e) => e.type === "done"); + + expect(deltas).toHaveLength(1); + expect(deltas[0]).toMatchObject({ type: "delta", text: "hello\n" }); + expect(done).toHaveLength(1); + expect(done[0]).toMatchObject({ type: "done", code: 0 }); + }); + + it("aider: enqueues remaining stdoutBuf as single delta on close (HTML, no trailing newline)", async () => { + const html = "hello from aider"; + + const events = await driveInvoke( + { agent: "aider", prompt: "make a page", binOverride: BIN_OVERRIDE }, + html, + 0, + ); + + const deltas = events.filter((e) => e.type === "delta"); + const done = events.filter((e) => e.type === "done"); + + expect(deltas).toHaveLength(1); + expect(deltas[0]).toMatchObject({ type: "delta", text: html }); + expect(done).toHaveLength(1); + expect(done[0]).toMatchObject({ type: "done", code: 0 }); + + expect(events).toHaveLength(3); + }); + + it("aider: does NOT double-enqueue partial line after complete lines", async () => { + const content = "aider-line\npartial"; + + const events = await driveInvoke( + { agent: "aider", prompt: "test", binOverride: BIN_OVERRIDE }, + content, + 0, + ); + + const deltas = events.filter((e) => e.type === "delta"); + + expect(deltas).toHaveLength(2); + expect(deltas[0]).toMatchObject({ type: "delta", text: "aider-line\n" }); + expect(deltas[1]).toMatchObject({ type: "delta", text: "partial" }); + }); + }); + + describe("close-path: non-deepseek/aider agents", () => { + it("codex: parses remaining stdoutBuf on close (valid JSON delta)", async () => { + const json = '{"type":"item.delta","text":"parsed on close"}'; + + const events = await driveInvoke( + { agent: "codex", prompt: "test", binOverride: BIN_OVERRIDE }, + json, + 0, + ); + + const deltas = events.filter((e) => e.type === "delta"); + const done = events.filter((e) => e.type === "done"); + + expect(deltas).toHaveLength(1); + expect(deltas[0]).toMatchObject({ type: "delta", text: "parsed on close" }); + expect(done).toHaveLength(1); + expect(done[0]).toMatchObject({ type: "done", code: 0 }); + }); + + it("codex: HTML content on close is not JSON-parsed (skipped)", async () => { + const html = "not json"; + + const events = await driveInvoke( + { agent: "codex", prompt: "test", binOverride: BIN_OVERRIDE }, + html, + 0, + ); + + const deltas = events.filter((e) => e.type === "delta"); + const done = events.filter((e) => e.type === "done"); + + expect(deltas).toHaveLength(0); + expect(done).toHaveLength(1); + expect(done[0]).toMatchObject({ type: "done", code: 0 }); + }); + + it("claude: parses remaining stdoutBuf on close (result usage meta)", async () => { + const json = '{"type":"result","usage":{"input_tokens":10,"output_tokens":20}}'; + + const events = await driveInvoke( + { agent: "claude", prompt: "test", binOverride: BIN_OVERRIDE }, + json, + 0, + ); + + const metas = events.filter((e) => e.type === "meta"); + const done = events.filter((e) => e.type === "done"); + + const usageMeta = metas.find( + (e) => e.type === "meta" && e.key === "usage", + ); + expect(usageMeta).toBeDefined(); + expect(done).toHaveLength(1); + }); + }); + + describe("exit code propagation", () => { + it("done event reflects exit code 0", async () => { + const events = await driveInvoke( + { agent: "deepseek", prompt: "test", binOverride: BIN_OVERRIDE }, + "ok", + 0, + ); + + const done = events.find((e) => e.type === "done"); + expect(done).toMatchObject({ type: "done", code: 0 }); + }); + + it("done event reflects exit code 1", async () => { + const events = await driveInvoke( + { agent: "deepseek", prompt: "test", binOverride: BIN_OVERRIDE }, + "fail", + 1, + ); + + const done = events.find((e) => e.type === "done"); + expect(done).toMatchObject({ type: "done", code: 1 }); + }); + + it("done event with null code (signal exit)", async () => { + const events = await driveInvoke( + { agent: "deepseek", prompt: "test", binOverride: BIN_OVERRIDE }, + "killed", + null, + ); + + const done = events.find((e) => e.type === "done"); + expect(done).toMatchObject({ type: "done", code: null }); + }); + }); + + describe("child process error", () => { + it("produces error event when child emits error", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "deepseek", + prompt: "test", + binOverride: BIN_OVERRIDE, + }); + + await new Promise((r) => setTimeout(r, 0)); + + const eventsPromise = collectStream(stream); + + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("error", new Error("spawn ENOENT")); + + const events = await eventsPromise; + + const errors = events.filter((e) => e.type === "error"); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + type: "error", + message: "spawn ENOENT", + }); + }); + }); + + describe("stderr propagation", () => { + it("produces stderr events for stderr output", async () => { + const { child, stdout, stderr } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "deepseek", + prompt: "test", + binOverride: BIN_OVERRIDE, + }); + + await new Promise((r) => setTimeout(r, 0)); + + const eventsPromise = collectStream(stream); + + stderr.write("warning: deprecated\n"); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + + const events = await eventsPromise; + + const stderrEvents = events.filter((e) => e.type === "stderr"); + expect(stderrEvents.length).toBeGreaterThanOrEqual(1); + expect(stderrEvents[0]).toMatchObject({ + type: "stderr", + text: "warning: deprecated\n", + }); + }); + }); + + describe("start event", () => { + it("includes bin, argv, and promptBytes", async () => { + const events = await driveInvoke( + { agent: "deepseek", prompt: "hello world", binOverride: BIN_OVERRIDE }, + null, + 0, + ); + + const start = events.find((e) => e.type === "start"); + expect(start).toBeDefined(); + if (start && start.type === "start") { + expect(start.bin).toBe(BIN_OVERRIDE); + expect(start.argv).toEqual( + expect.arrayContaining(["exec", "--auto"]), + ); + expect(start.promptBytes).toBe( + Buffer.byteLength("hello world", "utf8"), + ); + } + }); + }); + + describe("deepseek vs non-deepseek close-path distinction", () => { + it("deepseek close-path bypasses parse() entirely — HTML not double-parsed", async () => { + const html = "distinct"; + + const events = await driveInvoke( + { agent: "deepseek", prompt: "test", binOverride: BIN_OVERRIDE }, + html, + 0, + ); + + const deltas = events.filter((e) => e.type === "delta"); + expect(deltas).toHaveLength(1); + expect(deltas[0]).toMatchObject({ type: "delta", text: html }); + + const raws = events.filter((e) => e.type === "raw"); + expect(raws).toHaveLength(0); + + const htmls = events.filter((e) => e.type === "html"); + expect(htmls).toHaveLength(0); + }); + + it("aider close-path bypasses parse() entirely — HTML not double-parsed", async () => { + const html = "aider-distinct"; + + const events = await driveInvoke( + { agent: "aider", prompt: "test", binOverride: BIN_OVERRIDE }, + html, + 0, + ); + + const deltas = events.filter((e) => e.type === "delta"); + expect(deltas).toHaveLength(1); + expect(deltas[0]).toMatchObject({ type: "delta", text: html }); + + const raws = events.filter((e) => e.type === "raw"); + expect(raws).toHaveLength(0); + + const htmls = events.filter((e) => e.type === "html"); + expect(htmls).toHaveLength(0); + }); + }); +}); diff --git a/cli/src/__tests__/collision-resolve.test.ts b/cli/src/__tests__/collision-resolve.test.ts new file mode 100644 index 0000000..5c5aebd --- /dev/null +++ b/cli/src/__tests__/collision-resolve.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { findCommonPath, resolveCollisionOutput } from "../collision-resolve.js"; + +describe("findCommonPath", () => { + it('returns "" for empty array', () => { + expect(findCommonPath([])).toBe(""); + }); + + it("returns the resolved path of a single input", () => { + expect(findCommonPath(["/tmp/x"])).toBe("tmp/x"); + }); + + it("finds the common ancestor of two paths with a shared parent", () => { + expect( + findCommonPath(["/home/user/a/b/file.md", "/home/user/a/c/file.md"]), + ).toBe("home/user/a"); + }); + + it("returns path.sep when paths share only the root", () => { + expect(findCommonPath(["/foo/a.md", "/bar/b.md"])).toBe("/"); + }); +}); + +describe("resolveCollisionOutput", () => { + it("maps a file under a subdirectory relative to the common root", () => { + expect( + resolveCollisionOutput("/repo/a/b/readme.md", "/out", "/repo/a"), + ).toBe("/out/b/readme.html"); + }); + + it("preserves deeply nested directory structure under the common root", () => { + expect( + resolveCollisionOutput("/repo/x/y/z/doc.md", "/out", "/repo"), + ).toBe("/out/x/y/z/doc.html"); + }); + + it("falls back to plain basename.html when the file is directly in the common root directory", () => { + expect( + resolveCollisionOutput("/repo/a/readme.md", "/out", "/repo/a"), + ).toBe("/out/readme.html"); + }); + + it("filters out '..' and '.' segments and produces a clean relative output", () => { + expect( + resolveCollisionOutput("/outside/tmp/a.md", "/out", "/outside"), + ).toBe("/out/tmp/a.html"); + }); +}); diff --git a/cli/src/__tests__/extract-html.test.ts b/cli/src/__tests__/extract-html.test.ts new file mode 100644 index 0000000..91ec0bf --- /dev/null +++ b/cli/src/__tests__/extract-html.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { extractHtml } from "../extract-html.js"; + +describe("extractHtml", () => { + it('returns "" for agent error text ("rate limit exceeded")', () => { + expect(extractHtml("rate limit exceeded")).toBe(""); + }); + + it('returns "" for empty string input', () => { + expect(extractHtml("")).toBe(""); + }); + + it('returns "" for plain text without HTML markers ("Here is your result: some text")', () => { + expect(extractHtml("Here is your result: some text")).toBe(""); + }); + + it('returns "" for non-HTML fenced code block (```json {"a":1} ```)', () => { + expect(extractHtml('```json\n{"a":1}\n```')).toBe(""); + }); + + it("extracts DOCTYPE + full HTML from text with surrounding content", () => { + const input = "Sure!\nok"; + expect(extractHtml(input)).toBe("ok"); + }); + + it("extracts inner content from fenced HTML code block", () => { + const input = "```html\nok\n```"; + expect(extractHtml(input)).toBe("ok"); + }); + + it("returns content starting with < that is valid HTML snippet", () => { + const input = "

Hello

"; + expect(extractHtml(input)).toBe("

Hello

"); + }); + + it("returns HTML content when closing is missing (streaming)", () => { + const input = "streaming..."; + expect(extractHtml(input)).toBe("streaming..."); + }); + + it("returns plain tag content without DOCTYPE", () => { + const input = "\nok\n"; + expect(extractHtml(input)).toBe("\nok\n"); + }); +}); diff --git a/cli/src/__tests__/index.test.ts b/cli/src/__tests__/index.test.ts new file mode 100644 index 0000000..4bdb54d --- /dev/null +++ b/cli/src/__tests__/index.test.ts @@ -0,0 +1,446 @@ +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest"; + +const { + agentStore, + skillStore, + configStore, + invokeStore, + fsStore, + saveStore, + setAgents, + setSkills, + setConfig, + setInvokeStream, + getFileContents, + getSaveConfigCalls, + resetStores, +} = vi.hoisted(() => { + const agentStore = { + list: [ + { + id: "claude", + label: "Claude Code", + vendor: "Anthropic", + available: true, + protocol: "stdin" as const, + models: [] as { id: string; label: string }[], + unsupported: undefined as boolean | undefined, + }, + { + id: "hermes", + label: "Hermes", + vendor: "Mature", + available: true, + protocol: "acp" as const, + models: [] as { id: string; label: string }[], + unsupported: true, + }, + { + id: "pi", + label: "Pi", + vendor: "Inflection", + available: true, + protocol: "pi-rpc" as const, + models: [] as { id: string; label: string }[], + unsupported: true, + }, + { + id: "gemini", + label: "Gemini CLI", + vendor: "Google", + available: false, + protocol: "stdin" as const, + models: [] as { id: string; label: string }[], + unsupported: undefined as boolean | undefined, + }, + ], + }; + + const skillStore = { + list: [ + { + id: "valid-template", + zhName: "Valid Template", + enName: "Valid", + emoji: "📄", + description: "A valid template", + category: "doc", + scenario: "general", + aspectHint: "", + tags: [] as string[], + }, + ], + loaded: new Map([ + [ + "valid-template", + { + id: "valid-template", + zhName: "Valid Template", + enName: "Valid", + emoji: "📄", + description: "A valid template", + category: "doc", + scenario: "general", + aspectHint: "", + tags: [], + body: "You are a world-class HTML designer. Output complete HTML.", + }, + ], + ]), + }; + + const configStore = { + data: {} as Record, + saveError: null as Error | null, + }; + + const invokeStore = { + htmlOutput: "

Test

", + errorMessage: null as string | null, + exitCode: 0, + }; + + const fsStore = { + files: new Map(), + existing: new Set(), + mkdirCalls: [] as string[], + }; + + const saveStore = { + calls: [] as Array>, + }; + + const defaults = { + agents: JSON.parse(JSON.stringify(agentStore.list)), + skills: JSON.parse(JSON.stringify(skillStore.list)), + config: {}, + }; + + return { + agentStore, + skillStore, + configStore, + invokeStore, + fsStore, + saveStore, + setAgents: (a: typeof agentStore.list) => { + agentStore.list = a; + }, + setSkills: (s: typeof skillStore.list) => { + skillStore.list = s; + }, + setConfig: (c: Record) => { + configStore.data = c; + }, + setInvokeStream: (html: string, err?: string | null, code?: number) => { + invokeStore.htmlOutput = html; + invokeStore.errorMessage = err ?? null; + invokeStore.exitCode = code ?? 0; + }, + getFileContents: () => fsStore.files, + getSaveConfigCalls: () => saveStore.calls, + resetStores: () => { + agentStore.list = JSON.parse(JSON.stringify(defaults.agents)); + skillStore.list = JSON.parse(JSON.stringify(defaults.skills)); + configStore.data = { ...defaults.config }; + configStore.saveError = null; + invokeStore.htmlOutput = "

Test

"; + invokeStore.errorMessage = null; + invokeStore.exitCode = 0; + fsStore.files.clear(); + fsStore.existing.clear(); + fsStore.mkdirCalls = []; + saveStore.calls = []; + }, + }; +}); + +vi.mock("../agents-detect.js", () => ({ + detectAgents: vi.fn(() => [...agentStore.list]), +})); + +vi.mock("../skills-loader.js", () => ({ + listSkills: vi.fn(() => [...skillStore.list]), + loadSkill: vi.fn((_dir: string, id: string) => skillStore.loaded.get(id) ?? null), +})); + +function makeMockStream(): any { + const chunks: any[] = []; + if (invokeStore.errorMessage) { + chunks.push({ type: "error", message: invokeStore.errorMessage }); + } else { + chunks.push({ type: "delta", text: invokeStore.htmlOutput }); + chunks.push({ type: "done", code: invokeStore.exitCode }); + } + + let index = 0; + return { + getReader() { + return { + read() { + if (index < chunks.length) { + return Promise.resolve({ value: chunks[index++], done: false }); + } + return Promise.resolve({ value: undefined, done: true }); + }, + cancel() {}, + releaseLock() {}, + get closed() { + return index >= chunks.length; + }, + }; + }, + }; +} + +vi.mock("../agents-invoke.js", () => ({ + invokeAgent: vi.fn(() => makeMockStream()), +})); + +vi.mock("../config.js", () => ({ + loadConfig: vi.fn(() => ({ ...configStore.data })), + saveConfig: vi.fn((c: Record) => { + if (configStore.saveError) throw configStore.saveError; + configStore.data = { ...configStore.data, ...c }; + saveStore.calls.push({ ...c }); + }), + getConfigPath: vi.fn(() => "/fake/config.json"), +})); + +vi.mock("node:fs", () => { + const mockReadFileSync = vi.fn((p: string, _enc?: string) => { + if (fsStore.files.has(p)) return fsStore.files.get(p); + return "# Test markdown content"; + }); + const mockWriteFileSync = vi.fn((p: string, content: string) => { + fsStore.files.set(p, content); + }); + const mockExistsSync = vi.fn((p: string) => { + if (fsStore.files.has(p)) return true; + if (typeof p === "string" && p.endsWith("pnpm-workspace.yaml")) return true; + return false; + }); + const mockMkdirSync = vi.fn((p: string) => { + fsStore.mkdirCalls.push(p); + }); + const mockReaddirSync = vi.fn(() => [] as any[]); + + const fsNs = { + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + existsSync: mockExistsSync, + mkdirSync: mockMkdirSync, + readdirSync: mockReaddirSync, + }; + + return { + default: fsNs, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + existsSync: mockExistsSync, + mkdirSync: mockMkdirSync, + readdirSync: mockReaddirSync, + }; +}); + +vi.mock("../prompt.js", () => ({ + promptYesNo: vi.fn(async () => false), + promptOverwrite: vi.fn(async () => true), +})); + +let main: (args: string[]) => Promise; +let exitCode: number | null; +let stderrLines: string[]; +let stdoutLines: string[]; + +beforeAll(async () => { + const mod = await import("../index.js"); + main = mod.main; +}); + +beforeEach(() => { + exitCode = null; + stderrLines = []; + stdoutLines = []; + resetStores(); + + vi.spyOn(process, "exit").mockImplementation( + (code?: number | string | null): never => { + const c = typeof code === "number" ? code : 0; + exitCode = c; + throw new Error(`__EXIT_${c}__`); + }, + ); + vi.spyOn(console, "error").mockImplementation((...args: any[]) => { + stderrLines.push(args.join(" ")); + }); + vi.spyOn(console, "log").mockImplementation((...args: any[]) => { + stdoutLines.push(args.join(" ")); + }); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function stderrContains(substring: string): boolean { + return stderrLines.some((l) => l.includes(substring)); +} + +function stdoutContains(substring: string): boolean { + return stdoutLines.some((l) => l.includes(substring)); +} + +describe("main() parameter validation", () => { + it("rejects -o and -d used together", async () => { + await expect(main(["convert", "-o", "out.html", "-d", "out"])).rejects.toThrow("__EXIT_1__"); + expect(exitCode).toBe(1); + expect(stderrContains("cannot be used together")).toBe(true); + }); + + it("rejects -o with multiple input files", async () => { + await expect(main(["convert", "a.md", "b.md", "-o", "out.html"])).rejects.toThrow("__EXIT_1__"); + expect(exitCode).toBe(1); + expect(stderrContains("cannot be used with multiple input files")).toBe(true); + }); + + it('rejects unknown format "xml"', async () => { + await expect(main(["convert", "file.md", "--format", "xml"])).rejects.toThrow("__EXIT_1__"); + expect(exitCode).toBe(1); + expect(stderrContains("Unknown format")).toBe(true); + }); + + it("rejects unknown option", async () => { + await expect(main(["convert", "file.md", "--unknown"])).rejects.toThrow("__EXIT_1__"); + expect(exitCode).toBe(1); + expect(stderrContains("Unknown option")).toBe(true); + }); + + it("rejects unknown command", async () => { + await expect(main(["unknown-command"])).rejects.toThrow("__EXIT_1__"); + expect(exitCode).toBe(1); + expect(stderrContains("Unknown command")).toBe(true); + }); +}); + +describe("main() config set-default-agent", () => { + it('rejects unknown agent id "unknown"', async () => { + await expect(main(["config", "set-default-agent", "unknown"])).rejects.toThrow("__EXIT_1__"); + expect(exitCode).toBe(1); + expect(stderrContains("Unknown agent")).toBe(true); + }); + + it('rejects agent that is not installed (gemini)', async () => { + await expect(main(["config", "set-default-agent", "gemini"])).rejects.toThrow("__EXIT_1__"); + expect(exitCode).toBe(1); + expect(stderrContains("not installed")).toBe(true); + }); + + it('rejects agent with unsupported protocol (hermes — acp)', async () => { + await expect(main(["config", "set-default-agent", "hermes"])).rejects.toThrow("__EXIT_1__"); + expect(exitCode).toBe(1); + expect(stderrContains("unsupported protocol")).toBe(true); + }); + + it('rejects agent with unsupported protocol (pi — pi-rpc)', async () => { + await expect(main(["config", "set-default-agent", "pi"])).rejects.toThrow("__EXIT_1__"); + expect(exitCode).toBe(1); + expect(stderrContains("unsupported protocol")).toBe(true); + }); + + it("successfully sets claude as default agent", async () => { + await expect(main(["config", "set-default-agent", "claude"])).resolves.toBeUndefined(); + expect(exitCode).toBeNull(); + const calls = getSaveConfigCalls(); + expect(calls.length).toBeGreaterThanOrEqual(1); + expect(calls.some((c) => c.defaultAgent === "claude")).toBe(true); + }); + + it("rejects missing agent id", async () => { + await expect(main(["config", "set-default-agent"])).rejects.toThrow("__EXIT_1__"); + expect(exitCode).toBe(1); + expect(stderrContains("Specify an agent ID")).toBe(true); + }); +}); + +describe("main() template validation", () => { + it('rejects unknown template "nonexistent"', async () => { + await expect(main(["convert", "file.md", "-t", "nonexistent"])).rejects.toThrow("__EXIT_1__"); + expect(exitCode).toBe(1); + expect(stderrContains("Unknown template")).toBe(true); + }); + + it("rejects when no template specified and no default set", async () => { + setConfig({}); + await expect(main(["convert", "file.md"])).rejects.toThrow("__EXIT_1__"); + expect(exitCode).toBe(1); + expect(stderrContains("No template specified")).toBe(true); + }); +}); + +describe("main() agent not found", () => { + it('rejects when specified agent "nonexistent" is not available', async () => { + await expect(main(["convert", "file.md", "-t", "valid-template", "-a", "nonexistent"])).rejects.toThrow( + "__EXIT_1__", + ); + expect(exitCode).toBe(1); + expect(stderrContains("No available AI agent found")).toBe(true); + }); +}); + +describe("main() config save error handling", () => { + it("exits with error when saveConfig throws", async () => { + configStore.saveError = new Error("disk full"); + await expect(main(["config", "set-default-agent", "claude"])).rejects.toThrow("__EXIT_1__"); + expect(exitCode).toBe(1); + expect(stderrContains("disk full")).toBe(true); + }); +}); + +describe("main() convert success path with mocked agent", () => { + it("invokes agent and writes output file", async () => { + setInvokeStream("

Hello World

"); + + await expect(main(["convert", "file.md", "-t", "valid-template", "-a", "claude"])).resolves.toBeUndefined(); + expect(exitCode).toBeNull(); + + const files = getFileContents(); + const outputFile = [...files.keys()].find((k) => k.endsWith(".html")); + expect(outputFile).toBeTruthy(); + expect(files.get(outputFile!)).toContain("

Hello World

"); + }); +}); + +describe("main() --help and --version", () => { + it("prints help with --help flag", async () => { + await expect(main(["--help"])).resolves.toBeUndefined(); + expect(stdoutContains("html-anything — AI-powered Markdown to HTML converter")).toBe(true); + }); + + it("prints help with -h flag", async () => { + await expect(main(["-h"])).resolves.toBeUndefined(); + expect(stdoutContains("USAGE:")).toBe(true); + }); + + it("prints help with no arguments", async () => { + await expect(main([])).resolves.toBeUndefined(); + expect(stdoutContains("COMMANDS:")).toBe(true); + }); + + it("prints version with --version", async () => { + await expect(main(["--version"])).resolves.toBeUndefined(); + expect(stdoutContains("html-anything CLI v")).toBe(true); + }); + + it("prints version with -v", async () => { + await expect(main(["-v"])).resolves.toBeUndefined(); + expect(stdoutContains("html-anything CLI v")).toBe(true); + }); + + it("prints help from convert --help", async () => { + await expect(main(["convert", "--help"])).resolves.toBeUndefined(); + expect(stdoutContains("COMMANDS:")).toBe(true); + }); +}); diff --git a/cli/src/__tests__/prompt.test.ts b/cli/src/__tests__/prompt.test.ts new file mode 100644 index 0000000..a6d0c93 --- /dev/null +++ b/cli/src/__tests__/prompt.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { mockCreateInterface, setAnswer } = vi.hoisted(() => { + let answer = "n"; + return { + setAnswer: (a: string) => { + answer = a; + }, + mockCreateInterface: vi.fn(() => ({ + question: (_q: string, cb: (a: string) => void) => cb(answer), + close: vi.fn(), + })), + }; +}); + +vi.mock("node:readline", () => ({ + default: { createInterface: mockCreateInterface }, +})); + +import { promptYesNo, promptOverwrite } from "../prompt.js"; + +function setStdinTTY(value: boolean) { + Object.defineProperty(process.stdin, "isTTY", { + value, + configurable: true, + writable: true, + }); +} + +function setStderrTTY(value: boolean) { + Object.defineProperty(process.stderr, "isTTY", { + value, + configurable: true, + writable: true, + }); +} + +describe("promptYesNo", () => { + beforeEach(() => { + setStdinTTY(false); + setStderrTTY(false); + setAnswer("n"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("resolves false when stdin.isTTY is false and stderr.isTTY is true", async () => { + setStdinTTY(false); + setStderrTTY(true); + const result = await promptYesNo("Continue?"); + expect(result).toBe(false); + }); + + it("resolves false when stdin.isTTY is true and stderr.isTTY is false", async () => { + setStdinTTY(true); + setStderrTTY(false); + const result = await promptYesNo("Continue?"); + expect(result).toBe(false); + }); + + it("resolves false when both stdin and stderr are not TTY", async () => { + const result = await promptYesNo("Continue?"); + expect(result).toBe(false); + }); + + it("does not throw when both are TTY", () => { + setStdinTTY(true); + setStderrTTY(true); + expect(() => { + promptYesNo("Continue?"); + }).not.toThrow(); + }); + + it('resolves true when answer is "y" in TTY mode', async () => { + setStdinTTY(true); + setStderrTTY(true); + setAnswer("y"); + const result = await promptYesNo("Continue?"); + expect(result).toBe(true); + }); + + it('resolves false when answer is "n" in TTY mode', async () => { + setStdinTTY(true); + setStderrTTY(true); + setAnswer("n"); + const result = await promptYesNo("Continue?"); + expect(result).toBe(false); + }); +}); + +describe("promptOverwrite", () => { + beforeEach(() => { + setStdinTTY(false); + setStderrTTY(false); + setAnswer("n"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("resolves true (auto-overwrite) when stdin.isTTY is false and stderr.isTTY is true", async () => { + setStdinTTY(false); + setStderrTTY(true); + const result = await promptOverwrite("/some/file.txt"); + expect(result).toBe(true); + }); + + it("resolves true (auto-overwrite) when stdin.isTTY is true and stderr.isTTY is false", async () => { + setStdinTTY(true); + setStderrTTY(false); + const result = await promptOverwrite("/some/file.txt"); + expect(result).toBe(true); + }); + + it("resolves true (auto-overwrite) when both stdin and stderr are not TTY", async () => { + const result = await promptOverwrite("/some/file.txt"); + expect(result).toBe(true); + }); + + it('resolves true when answer is "y" in TTY mode', async () => { + setStdinTTY(true); + setStderrTTY(true); + setAnswer("y"); + const result = await promptOverwrite("/some/file.txt"); + expect(result).toBe(true); + }); + + it('resolves false when answer is "n" in TTY mode', async () => { + setStdinTTY(true); + setStderrTTY(true); + setAnswer("n"); + const result = await promptOverwrite("/some/file.txt"); + expect(result).toBe(false); + }); +}); diff --git a/cli/src/collision-resolve.ts b/cli/src/collision-resolve.ts new file mode 100644 index 0000000..ed68565 --- /dev/null +++ b/cli/src/collision-resolve.ts @@ -0,0 +1,29 @@ +import path from "node:path"; + +export function findCommonPath(dirs: string[]): string { + if (dirs.length === 0) return ""; + const resolved = dirs.map((d) => path.resolve(d)); + const segments = resolved.map((d) => d.split(path.sep).filter(Boolean)); + const minLen = Math.min(...segments.map((s) => s.length)); + let common = 0; + for (let i = 0; i < minLen; i++) { + const seg = segments[0][i]; + if (segments.every((s) => s[i] === seg)) common++; + else break; + } + return segments[0].slice(0, common).join(path.sep) || path.sep; +} + +export function resolveCollisionOutput(inputPath: string, outputDir: string, commonRoot: string): string { + const basename = path.basename(inputPath, path.extname(inputPath)); + const inputDir = path.resolve(path.dirname(inputPath)); + let relativeDir = path.relative(commonRoot, inputDir); + relativeDir = relativeDir + .split(path.sep) + .filter((s) => s !== ".." && s !== ".") + .join(path.sep); + if (relativeDir) { + return path.resolve(outputDir, relativeDir, `${basename}.html`); + } + return path.resolve(outputDir, `${basename}.html`); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index ee41445..0650832 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,6 +1,5 @@ import fs from "node:fs"; import path from "node:path"; -import readline from "node:readline"; import { fileURLToPath } from "node:url"; import { loadSkill, listSkills, type SkillMeta, type LoadedSkill } from "./skills-loader.js"; import { detectAgents, type DetectedAgent } from "./agents-detect.js"; @@ -8,6 +7,8 @@ import { assemblePrompt } from "./prompt-assemble.js"; import { invokeAgent, type InvokeEvent } from "./agents-invoke.js"; import { extractHtml } from "./extract-html.js"; import { loadConfig, saveConfig, getConfigPath, type CliConfig } from "./config.js"; +import { findCommonPath, resolveCollisionOutput } from "./collision-resolve.js"; +import { promptYesNo, promptOverwrite } from "./prompt.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -459,62 +460,6 @@ async function convertOne(opts: { } } -function findCommonPath(dirs: string[]): string { - if (dirs.length === 0) return ""; - const resolved = dirs.map((d) => path.resolve(d)); - const segments = resolved.map((d) => d.split(path.sep).filter(Boolean)); - const minLen = Math.min(...segments.map((s) => s.length)); - let common = 0; - for (let i = 0; i < minLen; i++) { - const seg = segments[0][i]; - if (segments.every((s) => s[i] === seg)) common++; - else break; - } - return segments[0].slice(0, common).join(path.sep) || path.sep; -} - -function resolveCollisionOutput(inputPath: string, outputDir: string, commonRoot: string): string { - const basename = path.basename(inputPath, path.extname(inputPath)); - const inputDir = path.resolve(path.dirname(inputPath)); - let relativeDir = path.relative(commonRoot, inputDir); - relativeDir = relativeDir - .split(path.sep) - .filter((s) => s !== ".." && s !== ".") - .join(path.sep); - if (relativeDir) { - return path.resolve(outputDir, relativeDir, `${basename}.html`); - } - return path.resolve(outputDir, `${basename}.html`); -} - -function promptYesNo(question: string): Promise { - return new Promise((resolve) => { - if (!process.stdin.isTTY || !process.stderr.isTTY) { - resolve(false); - return; - } - const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); - rl.question(question, (answer) => { - rl.close(); - resolve(answer.trim().toLowerCase() === "y"); - }); - }); -} - -function promptOverwrite(filepath: string): Promise { - return new Promise((resolve) => { - if (!process.stdin.isTTY || !process.stderr.isTTY) { - resolve(true); - return; - } - const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); - rl.question(`\x1b[33m⚠\x1b[0m ${filepath} already exists. Overwrite? (y/N): `, (answer) => { - rl.close(); - resolve(answer.trim().toLowerCase() === "y"); - }); - }); -} - function readStdin(): Promise { return new Promise((resolve) => { if (process.stdin.isTTY) { diff --git a/cli/src/prompt.ts b/cli/src/prompt.ts new file mode 100644 index 0000000..6aaf315 --- /dev/null +++ b/cli/src/prompt.ts @@ -0,0 +1,29 @@ +import readline from "node:readline"; + +export function promptYesNo(question: string): Promise { + return new Promise((resolve) => { + if (!process.stdin.isTTY || !process.stderr.isTTY) { + resolve(false); + return; + } + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +} + +export function promptOverwrite(filepath: string): Promise { + return new Promise((resolve) => { + if (!process.stdin.isTTY || !process.stderr.isTTY) { + resolve(true); + return; + } + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + rl.question(`\x1b[33m⚠\x1b[0m ${filepath} already exists. Overwrite? (y/N): `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +} diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts new file mode 100644 index 0000000..ae847ff --- /dev/null +++ b/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc2f9c1..9fe59df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: typescript: specifier: ^5 version: 5.9.3 + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@20.19.40)(happy-dom@20.9.0)(vite@8.0.13(@types/node@20.19.40)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.1)) e2e: devDependencies: From 0e23214cae039235c72fe56e08a98994b98558b1 Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Fri, 22 May 2026 10:19:37 +0800 Subject: [PATCH 12/16] fix(cli): resolve relative *_BIN overrides in tryPath tryPath() in resolveBinForAgent previously only handled absolute paths (starting with / or C:\) and command names on PATH. Relative paths like ./mock-deepseek or ../wrappers/claude fell through to resolveOnPath() which only searches PATH directories, causing a mismatch where detectAgents() reported the agent as available but invokeAgent() could not find it. Now paths containing / or \ or starting with . are resolved via path.resolve() + existsSync(), matching what detectAgents() does. --- cli/src/agents-invoke.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cli/src/agents-invoke.ts b/cli/src/agents-invoke.ts index 3ff3816..585815a 100644 --- a/cli/src/agents-invoke.ts +++ b/cli/src/agents-invoke.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync } from "node:fs"; +import path from "node:path"; import { resolveOnPath, AGENTS, type AgentDef, type AgentProtocol } from "./agents-detect.js"; export type InvokeOpts = { @@ -27,6 +28,10 @@ function resolveBinForAgent( if (/^([a-zA-Z]:[\\/]|[\\/])/.test(trimmed)) { return existsSync(trimmed) ? trimmed : null; } + if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.startsWith(".")) { + const resolved = path.resolve(trimmed); + return existsSync(resolved) ? resolved : null; + } return resolveOnPath(trimmed); }; if (binOverride && binOverride.trim()) { From a4210816ff59834d8e56378e9f93bf907b142cb9 Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Fri, 22 May 2026 10:26:58 +0800 Subject: [PATCH 13/16] test(cli): add relative *_BIN override resolution tests Two new test cases verify that invokeAgent correctly resolves relative binOverride paths (e.g. ./mock-agent, ../bin/claude) via path.resolve() + existsSync(), matching what detectAgents() already does. --- cli/src/__tests__/agents-invoke.test.ts | 56 ++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/cli/src/__tests__/agents-invoke.test.ts b/cli/src/__tests__/agents-invoke.test.ts index b73f902..e9e7367 100644 --- a/cli/src/__tests__/agents-invoke.test.ts +++ b/cli/src/__tests__/agents-invoke.test.ts @@ -2,12 +2,20 @@ import { vi, describe, it, expect, afterEach } from "vitest"; import { EventEmitter } from "node:events"; import { PassThrough, Writable } from "node:stream"; -const mockSpawn = vi.hoisted(() => vi.fn()); +const { mockSpawn, existsSyncDelegate } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), + existsSyncDelegate: vi.fn((p: string) => p === "/bin/sh"), +})); vi.mock("node:child_process", () => ({ spawn: mockSpawn, })); +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { ...actual, existsSync: existsSyncDelegate }; +}); + import { invokeAgent, type InvokeEvent } from "../agents-invoke.js"; function makeFakeChild() { @@ -71,7 +79,7 @@ const BIN_OVERRIDE = "/bin/sh"; describe("invokeAgent", () => { afterEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); }); describe("error cases", () => { @@ -124,6 +132,50 @@ describe("invokeAgent", () => { }); }); + describe("relative binOverride resolution", () => { + it("resolves ./mock-agent via path.resolve + existsSync", async () => { + existsSyncDelegate.mockImplementation((p: string) => { + if (p.endsWith("/mock-agent")) return true; + return p === "/bin/sh"; + }); + + const html = "ok"; + const events = await driveInvoke( + { agent: "deepseek", prompt: "test", binOverride: "./mock-agent" }, + html, + 0, + ); + + const start = events.find((e) => e.type === "start"); + expect(start).toBeDefined(); + expect(start).toMatchObject({ + type: "start", + bin: expect.stringContaining("/mock-agent"), + }); + }); + + it("resolves ../bin/claude wrapper relative path", async () => { + existsSyncDelegate.mockImplementation((p: string) => { + if (p.endsWith("/bin/claude")) return true; + return p === "/bin/sh"; + }); + + const html = "ok"; + const events = await driveInvoke( + { agent: "claude", prompt: "test", binOverride: "../bin/claude" }, + html, + 0, + ); + + const start = events.find((e) => e.type === "start"); + expect(start).toBeDefined(); + expect(start).toMatchObject({ + type: "start", + bin: expect.stringContaining("/bin/claude"), + }); + }); + }); + describe("close-path: deepseek and aider agents", () => { it("deepseek: enqueues remaining stdoutBuf as single delta on close (HTML, no trailing newline)", async () => { const html = "hello"; From f31eb7787718e96be4bbe80ab21deae5dce6f0d2 Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Thu, 21 May 2026 22:27:16 +0800 Subject: [PATCH 14/16] feat(cli): add auto command with intelligent template matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements automatic template detection for the CLI, partially resolves #60 and supplements the CLI entrypoint introduced in #75. - Add skills-matcher.ts with three-layer matching strategy: 1. ~80 strong-signal keyword rules (resume→resume-modern, etc.) 2. Full-template scoring (tags + name + description + scenario) 3. AI summary fallback only when confidence is low (~0 tokens) - Add `auto` command: html-anything auto article.md - Support --force-ai (skip rules) and --show-match-only flags - Update README with consolidated parameter docs and decision flowchart Examples: html-anything auto resume.md # auto-match + convert html-anything auto article.md --show-match-only # preview match only --- cli/README.md | 90 +++++++++++-- cli/src/index.ts | 221 ++++++++++++++++++++++++++++--- cli/src/skills-matcher.ts | 272 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 559 insertions(+), 24 deletions(-) create mode 100644 cli/src/skills-matcher.ts diff --git a/cli/README.md b/cli/README.md index f4efa31..022a60f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -47,6 +47,12 @@ html-anything config set-default-template doc-kami-parchment ### 2. 转换 Markdown 文件 ```bash +# 自动匹配模板(推荐:无需手动选模板) +html-anything auto article.md + +# 仅查看匹配结果,不执行转换 +html-anything auto article.md --show-match-only + # 使用默认模板转换(自动保存为 article.html) html-anything convert article.md @@ -78,22 +84,44 @@ open output.html ## 命令详解 -### `convert` — 转换内容 +### `convert` / `auto` — 转换内容 -```bash -html-anything convert [input] [options] -``` +两个命令共享以下通用参数: | 参数 | 简写 | 说明 | 默认值 | |------|------|------|--------| | `input` | — | 输入文件路径,省略则从 stdin 读取 | stdin | -| `--template ` | `-t` | 模板 ID | 配置中的 default-template | | `--agent ` | `-a` | AI agent ID | 自动检测第一个可用 agent | | `--output ` | `-o` | 输出文件路径 | 自动保存为 `<输入文件名>.html`,stdin 输入时输出到 stdout | | `--output-dir ` | `-d` | 自动保存目录 | 当前目录 | | `--model ` | — | 使用的模型 | agent 默认模型 | | `--format ` | — | 输入格式:markdown, text, csv, json | markdown | +#### `convert` — 指定模板转换 + +```bash +html-anything convert [input] [options] +``` + +用户明确指定模板 ID 来转换内容。 + +| 参数 | 简写 | 说明 | 默认值 | +|------|------|------|--------| +| `--template ` | `-t` | 模板 ID | 配置中的 default-template | + +#### `auto` — 自动匹配模板并转换 + +```bash +html-anything auto [input] [options] +``` + +无需手动选择模板,CLI 自动分析内容主题,从 75 个模板中匹配最合适的模板,然后执行转换。 + +| 参数 | 简写 | 说明 | 默认值 | +|------|------|------|--------| +| `--force-ai` | — | 跳过关键词匹配,强制使用 AI summary | — | +| `--show-match-only` | — | 仅显示匹配结果,不执行转换 | — | + ### `templates` — 列出模板 ```bash @@ -178,16 +206,19 @@ cat > my-article.md << 'EOF' 我们计划在 Q3 完成... EOF -# 4. 一键转换(自动保存为 my-article.html) -html-anything convert my-article.md +# 4. 一键自动匹配模板并转换(推荐) +html-anything auto my-article.md # 5. 在浏览器中查看结果 open my-article.html -# 6. 如果想换个风格 +# 6. 如果只想看匹配结果 +html-anything auto my-article.md --show-match-only + +# 7. 如果想换个风格 html-anything convert my-article.md -t blog-post -o my-article-v2.html -# 7. 保存到指定目录 +# 8. 保存到指定目录 html-anything convert my-article.md -d ./output ``` @@ -206,10 +237,51 @@ pnpm -F @html-anything/cli build ## 工作原理 +### `convert` 命令流程 + 1. **模板加载**:从 `next/src/lib/templates/skills/` 加载 75 个 SKILL 模板,每个模板包含视觉风格定义和排版规则 2. **Prompt 拼接**:将全局设计指令 + 模板专属规则 + 用户内容拼接成一个完整的 AI prompt 3. **Agent 调用**:调用本地安装的 AI agent CLI(如 Claude Code),让 AI 根据 prompt 生成 HTML 4. **HTML 提取**:从 agent 的流式输出中提取完整的 HTML 文档 5. **输出**:将 HTML 写入文件或打印到 stdout +### `auto` 命令流程 + +``` +用户内容 + │ + ▼ +┌─────────────────────────┐ +│ 第一层:强信号关键词匹配 │ ← 零 token,毫秒级 +│ 命中 → 直接使用匹配模板 │ +│ (简历→resume-modern 等) │ +└──────────┬──────────────┘ + │ 未命中 + ▼ +┌─────────────────────────┐ +│ 第二层:规则打分匹配 │ ← 零 token,毫秒级 +│ 内容 × 全部模板 metadata │ +│ (tags + name + desc + │ +│ scenario keywords) │ +│ 置信度 ≥ 阈值 → 使用 │ +└──────────┬──────────────┘ + │ 置信度不足 + ▼ +┌─────────────────────────┐ +│ 第三层:AI Summary 兜底 │ ← 仅在规则失配时 +│ 提取内容前 800 字 │ +│ → AI 判断主题类型 │ +│ → 再次规则匹配 │ +└──────────┬──────────────┘ + │ + ▼ + 执行转换 +``` + +**匹配策略说明**: +- **强信号**(~80 条规则):覆盖简历、定价、OKR、PRD、周报等高频场景,命中即定 +- **规则打分**:遍历所有模板的 tags、名称、描述、场景关键词,累加得分 +- **AI 兜底**:内容 ≥ 60 字且前两层均低置信度时,调用 AI 做一句话主题摘要,仅消耗极少量 token +- **最终兜底**:若所有层均失败,回退到 `deck-swiss-international` 通用模板 + 整个过程完全本地运行,不依赖任何外部 API key,使用你已有的 agent 订阅。转换过程中会显示动画进度指示器,展示已接收的文本块数和耗时。 \ No newline at end of file diff --git a/cli/src/index.ts b/cli/src/index.ts index 0650832..a67b0f4 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import readline from "node:readline"; import { fileURLToPath } from "node:url"; import { loadSkill, listSkills, type SkillMeta, type LoadedSkill } from "./skills-loader.js"; import { detectAgents, type DetectedAgent } from "./agents-detect.js"; @@ -7,8 +8,7 @@ import { assemblePrompt } from "./prompt-assemble.js"; import { invokeAgent, type InvokeEvent } from "./agents-invoke.js"; import { extractHtml } from "./extract-html.js"; import { loadConfig, saveConfig, getConfigPath, type CliConfig } from "./config.js"; -import { findCommonPath, resolveCollisionOutput } from "./collision-resolve.js"; -import { promptYesNo, promptOverwrite } from "./prompt.js"; +import { matchTemplate, type MatchResult } from "./skills-matcher.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -74,6 +74,16 @@ COMMANDS: --model Model to use (optional) --format Input format: markdown, text, csv, json (default: markdown) + auto [input] Auto-detect best template and convert + input Input file, or use stdin if omitted + --agent, -a Agent ID (default: auto-detect) + --output, -o Output file path + --output-dir, -d Output directory for auto-saved files + --model Model to use (optional) + --format Input format: markdown, text, csv, json (default: markdown) + --force-ai Force AI summary for matching + --show-match-only Show match result, skip conversion + templates List all available templates agents List detected AI agents @@ -85,6 +95,11 @@ COMMANDS: config reset Reset all configuration EXAMPLES: + html-anything auto article.md + html-anything auto article.md -o output.html + html-anything auto article.md --show-match-only + html-anything auto article.md --force-ai + cat article.md | html-anything auto html-anything convert article.md html-anything convert article.md -t doc-kami-parchment -o output.html html-anything convert article.md -t doc-kami-parchment -d ./dist @@ -243,25 +258,20 @@ async function handleConvert(args: string[]): Promise { } const collisions = [...basenameCounts].filter(([, paths]) => paths.length > 1); - let useRelative = collisions.length > 0; if (collisions.length > 0) { console.error(`\x1b[33m⚠\x1b[0m Multiple inputs would produce the same output basename:`); for (const [basename, paths] of collisions) { console.error(` ${basename}:`); for (const p of paths) console.error(` → ${p}`); } - if (process.stdin.isTTY && process.stderr.isTTY) { - useRelative = await promptYesNo( - "\x1b[33m⚠\x1b[0m Save with relative directory paths (e.g. dir1/readme.html)? (y/N): ", + const useRelative = await promptYesNo( + "\x1b[33m⚠\x1b[0m Save with relative directory paths (e.g. dir1/readme.html)? (y/N): ", + ); + if (!useRelative) { + console.error( + "Aborted. Rename your input files to use different basenames, or use --output (-o) for each file.", ); - if (!useRelative) { - console.error( - "Aborted. Rename your input files to use different basenames, or use --output (-o) for each file.", - ); - process.exit(1); - } - } else { - console.error(`\x1b[33m⚠\x1b[0m Auto-enabling relative directory paths (non-interactive mode).`); + process.exit(1); } } @@ -270,7 +280,7 @@ async function handleConvert(args: string[]): Promise { const outputPlan = inputPaths.map((p) => ({ inputPath: p, - outputPath: useRelative + outputPath: collisions.length > 0 ? resolveCollisionOutput(p, outputDir, commonRoot) : path.resolve(outputDir, `${path.basename(p, path.extname(p))}.html`), })); @@ -460,6 +470,62 @@ async function convertOne(opts: { } } +function findCommonPath(dirs: string[]): string { + if (dirs.length === 0) return ""; + const resolved = dirs.map((d) => path.resolve(d)); + const segments = resolved.map((d) => d.split(path.sep).filter(Boolean)); + const minLen = Math.min(...segments.map((s) => s.length)); + let common = 0; + for (let i = 0; i < minLen; i++) { + const seg = segments[0][i]; + if (segments.every((s) => s[i] === seg)) common++; + else break; + } + return segments[0].slice(0, common).join(path.sep) || path.sep; +} + +function resolveCollisionOutput(inputPath: string, outputDir: string, commonRoot: string): string { + const basename = path.basename(inputPath, path.extname(inputPath)); + const inputDir = path.resolve(path.dirname(inputPath)); + let relativeDir = path.relative(commonRoot, inputDir); + relativeDir = relativeDir + .split(path.sep) + .filter((s) => s !== ".." && s !== ".") + .join(path.sep); + if (relativeDir) { + return path.resolve(outputDir, relativeDir, `${basename}.html`); + } + return path.resolve(outputDir, `${basename}.html`); +} + +function promptYesNo(question: string): Promise { + return new Promise((resolve) => { + if (!process.stdin.isTTY || !process.stderr.isTTY) { + resolve(false); + return; + } + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +} + +function promptOverwrite(filepath: string): Promise { + return new Promise((resolve) => { + if (!process.stdin.isTTY || !process.stderr.isTTY) { + resolve(true); + return; + } + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + rl.question(`\x1b[33m⚠\x1b[0m ${filepath} already exists. Overwrite? (y/N): `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +} + function readStdin(): Promise { return new Promise((resolve) => { if (process.stdin.isTTY) { @@ -473,6 +539,128 @@ function readStdin(): Promise { }); } +async function handleAuto(args: string[]): Promise { + const flags: Record = {}; + let forceAi = false; + let showMatchOnly = false; + const positional: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--force-ai") { + forceAi = true; + } else if (arg === "--show-match-only") { + showMatchOnly = true; + } else if (arg === "--agent" || arg === "-a") { + flags.agent = args[++i] ?? ""; + } else if (arg === "--output" || arg === "-o") { + flags.output = args[++i] ?? ""; + } else if (arg === "--output-dir" || arg === "-d") { + flags.outputDir = args[++i] ?? ""; + } else if (arg === "--model") { + flags.model = args[++i] ?? ""; + } else if (arg === "--format") { + flags.format = args[++i] ?? ""; + } else if (arg === "--help" || arg === "-h") { + printHelp(); + return; + } else if (!arg.startsWith("-")) { + positional.push(arg); + } else { + console.error(`Unknown option: ${arg}`); + process.exit(1); + } + } + + if (flags.output && flags.outputDir) { + console.error("Error: --output (-o) and --output-dir (-d) cannot be used together."); + process.exit(1); + } + + if (showMatchOnly && flags.output) { + console.error("Error: --show-match-only cannot be used with --output (-o)."); + process.exit(1); + } + + const VALID_FORMATS = ["markdown", "text", "csv", "json"]; + const format = flags.format ?? "markdown"; + if (!VALID_FORMATS.includes(format)) { + console.error(`Error: Unknown format "${format}". Supported: ${VALID_FORMATS.join(", ")}`); + process.exit(1); + } + + const config = loadConfig(); + + const agent = findAgent(flags.agent); + if (!agent) { + const wantId = flags.agent ?? config.defaultAgent ?? "(auto-detect)"; + console.error(`Error: No available AI agent found${flags.agent ? ` for "${wantId}"` : ""}.`); + console.error("\nDetected agents:"); + for (const a of getAvailableAgents()) { + const status = a.available ? (a.unsupported ? "(unsupported)" : "✓") : "✗"; + console.error(` ${status} ${a.id} — ${a.label}`); + } + console.error("\nInstall one of the supported agents (e.g. 'claude', 'codex', 'gemini') and try again."); + process.exit(1); + } + + const model = flags.model ?? config.model; + + const inputPath = positional.length > 0 ? positional[0] : null; + let content: string; + if (inputPath) { + try { + content = fs.readFileSync(inputPath, "utf-8"); + } catch (err) { + console.error(`Error: Cannot read "${inputPath}": ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + } else { + content = await readStdin(); + if (!content.trim()) { + console.error("Error: No input provided. Pipe content via stdin or specify an input file."); + process.exit(1); + } + } + + const templates = getAvailableTemplates(); + if (templates.length === 0) { + console.error("Error: No templates found."); + process.exit(1); + } + + const label = inputPath ? path.basename(inputPath) : "stdin"; + console.error(`Matching template for: ${label}`); + console.error(`Agent: ${agent.label} (${agent.id})`); + if (model) console.error(`Model: ${model}`); + console.error(""); + + const result = await matchTemplate( + content, + templates, + SKILLS_DIR, + agent.id, + forceAi, + ); + + console.error(`Matched: ${result.zhName} (${result.templateId})`); + console.error(`Confidence: ${result.confidence}/10`); + console.error(`Reason: ${result.reason}`); + + if (showMatchOnly) return; + + console.error(""); + + const skill = getTemplate(result.templateId); + if (!skill) { + console.error(`Error: Unknown template "${result.templateId}"`); + process.exit(1); + } + + const ok = await convertOne({ inputPath, content, skill, agent, model, format, flags }); + if (!ok) process.exit(1); +} + function handleTemplates(): void { const templates = getAvailableTemplates(); @@ -642,6 +830,9 @@ export async function main(args: string[]): Promise { case "convert": await handleConvert(rest); break; + case "auto": + await handleAuto(rest); + break; case "templates": handleTemplates(); break; diff --git a/cli/src/skills-matcher.ts b/cli/src/skills-matcher.ts new file mode 100644 index 0000000..0f5b249 --- /dev/null +++ b/cli/src/skills-matcher.ts @@ -0,0 +1,272 @@ +import { loadSkill, type SkillMeta } from "./skills-loader.js"; +import { invokeAgent } from "./agents-invoke.js"; + +const CONFIDENCE_THRESHOLD = 3; +const MIN_CONTENT_LENGTH_FOR_AI = 60; + +const STRONG_SIGNALS: [keywords: string[], templateId: string][] = [ + [["简历", "resume", "CV", "求职", "工作经历", "教育背景"], "resume-modern"], + [["定价", "pricing", "价格方案", "套餐", "plans", "免费版", "专业版"], "pricing-page"], + [["OKR", "KR", "关键结果", "objectives", "季度目标", "key results"], "team-okrs"], + [["PRD", "产品需求", "spec", "user stories", "用户故事", "需求文档", "功能规格"], "pm-spec"], + [["周报", "weekly", "本周", "下周计划", "本周完成", "TODO"], "weekly-update"], + [["Runbook", "runbook", "oncall", "on-call", "SRE", "告警", "运维", "事故", "incident"], "eng-runbook"], + [["发票", "invoice", "账单", "税率", "tax", "收款", "付款方"], "invoice"], + [["入职", "onboarding", "新人", "onboard", "欢迎", "新员工"], "hr-onboarding"], + [["SaaS", "landing", "落地页", "产品落地", "hero", "social proof"], "saas-landing"], + [["等候", "waitlist", "预发布", "coming soon", "即将上线", "预约"], "waitlist-page"], + [["会议纪要", "meeting", "notes", "会议记录", "参会人", "action items"], "meeting-notes"], + [["看板", "kanban", "board", "todo", "doing", "done"], "kanban-board"], + [["Dashboard", "dashboard", "仪表板", "仪表盘", "KPI", "指标", "监控"], "dashboard"], + [["日报", "社媒", "社交媒体", "social media", "粉丝", "follower"], "social-media-dashboard"], + [["博客", "blog", "文章", "长文", "杂志", "magazine", "公众号", "newsletter", "essay", "写作"], "article-magazine"], + [["blog", "post", "技术博客", "blog post", "写作"], "blog-post"], + [["文档", "docs", "API文档", "技术文档", "documentation", "doc"], "docs-page"], + [["小红书", "xiaohongshu", "红书", "RED"], "card-xiaohongshu"], + [["推特", "twitter", "X", "tweet", "推文", "𝕏"], "social-x-post-card"], + [["Spotify", "spotify", "正在播放", "now playing", "音乐", "专辑"], "social-spotify-card"], + [["Reddit", "reddit", "投票", "upvote"], "social-reddit-card"], + [["Instagram", "linkedin", "carousel", "轮播", "三联", "thread"], "social-carousel"], + [["海报", "poster", "宣传", "海报设计", "营销海报", "视觉冲击"], "poster-hero"], + [["财报", "finance report", "财务报表", "年报", "季度财报", "利润表", "资产负债表"], "finance-report"], + [["数据报告", "data report", "数据分析", "可视化", "图表", "chart", "statistics"], "data-report"], + [["直播", "live", "弹幕", "chat", "实时数据"], "live-dashboard"], + [["PPT", "幻灯片", "deck", "slides", "presentation", "演讲", "演示", "keynote"], "deck-swiss-international"], + [["原型", "prototype", "线框", "wireframe", "mockup", "低保真", "sketch", "手绘"], "prototype-web"], + [["视频", "video", "帧动画", "hyperframes", "remotion", "mp4", "片头"], "video-hyperframes"], + [["VFX", "特效", "光标", "cursor", "chromatic", "reveal"], "vfx-text-cursor"], + [["恋爱", "dating", "交友", "匹配", "约会", "profile"], "dating-web"], + [["App", "mobile", "手机", "H5", "移动端", "小程序"], "mobile-app"], + [["课程", "course", "模块", "module", "教学", "教程", "课时"], "deck-course-module"], + [["team dashboard", "flow", "工单", "ticket", "flowai"], "flowai-team-dashboard"], + [["eguide", "电子指南", "guide", "手册", "指南"], "digital-eguide"], + [["羊皮纸", "kami", "parchment", "古风", "东方", "传统"], "doc-kami-parchment"], + [["email", "营销邮件", "邮件", "EDM", "newsletter email"], "email-marketing"], + [["苹果", "Apple", "iOS", "soft", "squircle"], "web-proto-soft"], + [["brutalist", "工业", "粗野", "swiss", "industrial print"], "web-proto-brutalist"], + [["editorial", "编辑", "杂志风", "serif", "排版"], "web-proto-editorial"], + [["gamified", "游戏化", "成就", "徽章", "badge", "积分"], "gamified-app"], + [["macOS", "notification", "通知", "桌面通知", "弹窗"], "frame-macos-notification"], + [["glitch", "故障", "cyber", "赛博", "cyberpunk"], "deck-hermes-cyber"], + [["glitch", "title", "故障标题"], "frame-glitch-title"], + [["liquid", "流体", "blob", "渐变背景", "hero"], "frame-liquid-bg-hero"], + [["logo", "outro", "片尾", "结尾", "brand"], "frame-logo-outro"], + [["light leak", "cinema", "电影感", "漏光", "胶片"], "frame-light-leak-cinema"], + [["flowchart", "流程图", "sticky", "便利贴", "流程"], "frame-flowchart-sticky"], + [["chart", "NYT", "数据图", "data chart", "新闻图表", "infographic"], "frame-data-chart-nyt"], + [["3D", "3d", "mockup", "device", "iPhone", "MacBook", "立体"], "mockup-device-3d"], + [["pixel", "8-bit", "像素", "复古", "游戏"], "sprite-animation"], + [["motion", "动效", "循环", "动画", "CSS动画"], "motion-frames"], + [["pitch", "融资", "pitch deck", "路演", "投资人", "BP"], "deck-pitch"], + [["product", "launch", "产品发布", "product launch", "上线"], "deck-product-launch"], + [["simple", "deck", "简单", "简洁", "简约"], "deck-simple"], + [["swiss", "international", "瑞士", "国际", "现代主义"], "deck-swiss-international"], + [["graphify", "graph", "dark", "暗色", "图表", "可视化"], "deck-graphify-dark"], + [["obsidian", "黑曜石", "笔记", "知识"], "deck-obsidian-claude"], + [["guizang", "贵赞", "墨水", "ink", "editorial deck"], "deck-guizang-editorial"], + [["magazine", "web", "杂志网页"], "deck-magazine-web"], + [["safety", "alert", "安全", "告警", "warning"], "deck-safety-alert"], + [["blueprint", "蓝图", "工程"], "deck-blueprint"], + [["replit", "terminal", "终端", "暗色"], "deck-replit"], + [["keyboard", "nav", "键盘导航"], "deck-dir-key-nav"], + [["open slide", "canvas", "画布", "自由"], "deck-open-slide-canvas"], + [["presenter", "演讲者", "演讲模式"], "deck-presenter-mode"], + [["tech", "sharing", "技术分享", "tech talk"], "deck-tech-sharing"], + [["xhs", "xiaohongshu deck"], "deck-xhs-post"], + [["XHS", "pastel", "粉彩", "柔和", "粉色"], "deck-xhs-pastel"], + [["XHS", "white", "白色", "纯净", "极简红书"], "deck-xhs-white"], + [["mobile", "onboarding", "引导", "启动"], "mobile-onboarding"], + [["magazine", "poster", "杂志海报", "杂志风海报"], "magazine-poster"], +]; + +const SCENARIO_SIGNALS: Record = { + marketing: ["marketing", "推广", "营销", "广告", "品牌", "brand", "campaign", "landing page", "着陆页", "落地", "转换", "conversion"], + engineering: ["代码", "code", "编程", "engineering", "工程", "开发", "部署", "deploy", "CI/CD", "git", "架构", "arch", "SRE", "devops", "运维", "oncall"], + operations: ["运营", "operation", "ops", "周报", "每周", "weekly", "standup", "站会", "流程", "process", "复盘", "retro"], + product: ["产品", "product", "PM", "PRD", "需求", "requirement", "feature", "功能", "spec", "roadmap", "路线图", "OKR", "目标", "用户故事", "user story"], + design: ["设计", "design", "UI", "UX", "原型", "prototype", "mockup", "wireframe", "线框", "配色", "字体", "typography"], + finance: ["财务", "finance", "财报", "利润", "revenue", "收入", "支出", "expense", "账单", "发票", "invoice", "tax", "税率"], + sales: ["销售", "sales", "定价", "pricing", "plan", "套餐", "订阅", "subscription", "折扣", "discount"], + hr: ["HR", "人事", "招聘", "hire", "入职", "onboard", "简历", "resume", "CV", "面试", "interview", "员工", "employee"], + personal: ["个人", "personal", "简历", "resume", "CV", "spotify", "音乐", "music"], + education: ["教育", "education", "课程", "course", "教程", "tutorial", "学习", "learn", "教学"], + creator: ["创作者", "creator", "内容创作", "社媒", "social media", "粉丝", "follower", "自媒体", "KOL"], + video: ["视频", "video", "帧", "frame", "hyperframes", "remotion", "动画", "animation", "VFX", "片头", "intro"], +}; + +export interface MatchResult { + templateId: string; + zhName: string; + confidence: number; + reason: string; +} + +function normalize(text: string): string { + return text.toLowerCase().replace(/[,,。;;!!??、\s]+/g, " ").trim(); +} + +function ruleMatch(content: string, meta: SkillMeta): number { + const lower = normalize(content); + let score = 0; + + for (const tag of meta.tags) { + if (lower.includes(tag.toLowerCase())) score += 3; + } + + const nameWords = normalize(meta.zhName).split(" ").filter((w) => w.length >= 2); + const descWords = normalize(meta.description).split(" ").filter((w) => w.length >= 3); + + for (const w of nameWords) { + if (lower.includes(w)) score += 2; + } + for (const w of descWords) { + if (lower.includes(w)) score += 1; + } + + const scenarioKeywords = SCENARIO_SIGNALS[meta.scenario] ?? []; + for (const kw of scenarioKeywords) { + if (lower.includes(kw.toLowerCase())) score += 1; + } + + return score; +} + +function strongSignalMatch( + content: string, + skillsDir: string, +): MatchResult | null { + let best: { templateId: string; confidence: number; matched: string[] } | null = null; + const lower = normalize(content); + + for (const [keywords, templateId] of STRONG_SIGNALS) { + const matched = keywords.filter((kw) => lower.includes(kw.toLowerCase())); + if (matched.length > 0) { + const confidence = matched.length * 2 + 3; + if (!best || confidence > best.confidence) { + best = { templateId, confidence, matched }; + } + } + } + + if (best && best.confidence >= CONFIDENCE_THRESHOLD) { + const meta = loadSkill(skillsDir, best.templateId); + const name = meta?.zhName ?? best.templateId; + return { + templateId: best.templateId, + zhName: name, + confidence: best.confidence, + reason: `关键词命中: ${best.matched.join(", ")} → ${name}`, + }; + } + + return null; +} + +function fallbackMatch(content: string, templates: SkillMeta[]): MatchResult | null { + let best: SkillMeta | null = null; + let bestScore = 0; + + for (const t of templates) { + const score = ruleMatch(content, t); + if (score > bestScore) { + bestScore = score; + best = t; + } + } + + if (!best || bestScore < 1) return null; + + return { + templateId: best.id, + zhName: best.zhName, + confidence: bestScore, + reason: `最佳匹配: ${best.zhName} (scenario: ${best.scenario}, score: ${bestScore})`, + }; +} + +async function aiSummaryMatch( + content: string, + templates: SkillMeta[], + agentId: string, +): Promise { + let summary = ""; + try { + const prompt = `请用一句话(不超过15个字)描述以下内容最像什么类型的文档,只需回答类型名称: + +${content.slice(0, 800)} + +类型名称:`; + + const stream = invokeAgent({ agent: agentId, prompt }); + const reader = stream.getReader(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value?.type === "delta") summary += value.text; + if (value?.type === "html") summary = value.text; + if (value?.type === "error") return null; + } + } catch { + return null; + } + + const clean = normalize(summary).trim(); + if (!clean || clean.length > 100) return null; + + return fallbackMatch(clean, templates); +} + +export async function matchTemplate( + content: string, + templates: SkillMeta[], + skillsDir: string, + agentId: string, + forceAi: boolean = false, +): Promise { + if (!forceAi && content.length >= MIN_CONTENT_LENGTH_FOR_AI) { + const strong = strongSignalMatch(content, skillsDir); + if (strong) { + strong.confidence = Math.min(strong.confidence, 10); + return strong; + } + } + + const rule = fallbackMatch(content, templates); + if (rule && rule.confidence >= CONFIDENCE_THRESHOLD) { + rule.confidence = Math.min(rule.confidence, 10); + return rule; + } + + if (content.length < MIN_CONTENT_LENGTH_FOR_AI) { + if (rule) { + rule.confidence = Math.min(rule.confidence, 10); + return rule; + } + const fallback = templates.find((t) => t.id === "deck-swiss-international") ?? templates[0]; + return { + templateId: fallback.id, + zhName: fallback.zhName, + confidence: 1, + reason: "内容较短,使用通用模板", + }; + } + + const ai = await aiSummaryMatch(content, templates, agentId); + if (ai) { + ai.confidence = Math.min(ai.confidence, 10); + return ai; + } + + if (rule) { + rule.confidence = Math.min(rule.confidence, 10); + return rule; + } + + const fallback = templates.find((t) => t.id === "deck-swiss-international") ?? templates[0]; + return { + templateId: fallback.id, + zhName: fallback.zhName, + confidence: 1, + reason: "无法确定主题,使用默认模板", + }; +} From e13bad2d8328cb9a86a262a824c77a852fa0b245 Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Fri, 22 May 2026 11:04:47 +0800 Subject: [PATCH 15/16] fix(cli): word-boundary keyword matching, force-ai gating, EPIPE guard - Add kwMatches() with \b word-boundary for ASCII keywords, substring for CJK - Remove ambiguous short keywords: "X", "RED", "TODO", "done", "doing", "todo" - Gate Layer-2 fallback on !forceAi so --force-ai reaches AI summary - Add EPIPE guard to handleAuto stdin-to-stdout path (matching handleConvert) --- cli/src/index.ts | 3 +++ cli/src/skills-matcher.ts | 32 ++++++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/cli/src/index.ts b/cli/src/index.ts index a67b0f4..46b9014 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -621,6 +621,9 @@ async function handleAuto(args: string[]): Promise { console.error("Error: No input provided. Pipe content via stdin or specify an input file."); process.exit(1); } + process.stdout.on("error", (err) => { + if ((err as NodeJS.ErrnoException).code === "EPIPE") process.exit(0); + }); } const templates = getAvailableTemplates(); diff --git a/cli/src/skills-matcher.ts b/cli/src/skills-matcher.ts index 0f5b249..9c5d5cb 100644 --- a/cli/src/skills-matcher.ts +++ b/cli/src/skills-matcher.ts @@ -9,21 +9,21 @@ const STRONG_SIGNALS: [keywords: string[], templateId: string][] = [ [["定价", "pricing", "价格方案", "套餐", "plans", "免费版", "专业版"], "pricing-page"], [["OKR", "KR", "关键结果", "objectives", "季度目标", "key results"], "team-okrs"], [["PRD", "产品需求", "spec", "user stories", "用户故事", "需求文档", "功能规格"], "pm-spec"], - [["周报", "weekly", "本周", "下周计划", "本周完成", "TODO"], "weekly-update"], + [["周报", "weekly", "本周", "下周计划", "本周完成"], "weekly-update"], [["Runbook", "runbook", "oncall", "on-call", "SRE", "告警", "运维", "事故", "incident"], "eng-runbook"], [["发票", "invoice", "账单", "税率", "tax", "收款", "付款方"], "invoice"], [["入职", "onboarding", "新人", "onboard", "欢迎", "新员工"], "hr-onboarding"], [["SaaS", "landing", "落地页", "产品落地", "hero", "social proof"], "saas-landing"], [["等候", "waitlist", "预发布", "coming soon", "即将上线", "预约"], "waitlist-page"], [["会议纪要", "meeting", "notes", "会议记录", "参会人", "action items"], "meeting-notes"], - [["看板", "kanban", "board", "todo", "doing", "done"], "kanban-board"], + [["看板", "kanban", "board"], "kanban-board"], [["Dashboard", "dashboard", "仪表板", "仪表盘", "KPI", "指标", "监控"], "dashboard"], [["日报", "社媒", "社交媒体", "social media", "粉丝", "follower"], "social-media-dashboard"], [["博客", "blog", "文章", "长文", "杂志", "magazine", "公众号", "newsletter", "essay", "写作"], "article-magazine"], [["blog", "post", "技术博客", "blog post", "写作"], "blog-post"], [["文档", "docs", "API文档", "技术文档", "documentation", "doc"], "docs-page"], - [["小红书", "xiaohongshu", "红书", "RED"], "card-xiaohongshu"], - [["推特", "twitter", "X", "tweet", "推文", "𝕏"], "social-x-post-card"], + [["小红书", "xiaohongshu", "红书"], "card-xiaohongshu"], + [["推特", "twitter", "tweet", "推文", "𝕏"], "social-x-post-card"], [["Spotify", "spotify", "正在播放", "now playing", "音乐", "专辑"], "social-spotify-card"], [["Reddit", "reddit", "投票", "upvote"], "social-reddit-card"], [["Instagram", "linkedin", "carousel", "轮播", "三联", "thread"], "social-carousel"], @@ -105,12 +105,28 @@ function normalize(text: string): string { return text.toLowerCase().replace(/[,,。;;!!??、\s]+/g, " ").trim(); } +function isAscii(s: string): boolean { + return /^[\x00-\x7F]+$/.test(s); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function kwMatches(haystack: string, needle: string): boolean { + const kw = needle.toLowerCase(); + if (isAscii(kw)) { + return new RegExp("\\b" + escapeRegExp(kw) + "\\b", "i").test(haystack); + } + return haystack.includes(kw); +} + function ruleMatch(content: string, meta: SkillMeta): number { const lower = normalize(content); let score = 0; for (const tag of meta.tags) { - if (lower.includes(tag.toLowerCase())) score += 3; + if (kwMatches(lower, tag)) score += 3; } const nameWords = normalize(meta.zhName).split(" ").filter((w) => w.length >= 2); @@ -125,7 +141,7 @@ function ruleMatch(content: string, meta: SkillMeta): number { const scenarioKeywords = SCENARIO_SIGNALS[meta.scenario] ?? []; for (const kw of scenarioKeywords) { - if (lower.includes(kw.toLowerCase())) score += 1; + if (kwMatches(lower, kw)) score += 1; } return score; @@ -139,7 +155,7 @@ function strongSignalMatch( const lower = normalize(content); for (const [keywords, templateId] of STRONG_SIGNALS) { - const matched = keywords.filter((kw) => lower.includes(kw.toLowerCase())); + const matched = keywords.filter((kw) => kwMatches(lower, kw)); if (matched.length > 0) { const confidence = matched.length * 2 + 3; if (!best || confidence > best.confidence) { @@ -232,7 +248,7 @@ export async function matchTemplate( } const rule = fallbackMatch(content, templates); - if (rule && rule.confidence >= CONFIDENCE_THRESHOLD) { + if (!forceAi && rule && rule.confidence >= CONFIDENCE_THRESHOLD) { rule.confidence = Math.min(rule.confidence, 10); return rule; } From af5ee904f2b9e4f8ab03c433ef28b5e7c004c3da Mon Sep 17 00:00:00 2001 From: Chen Mofei Date: Fri, 22 May 2026 11:20:33 +0800 Subject: [PATCH 16/16] fix(cli): address PR #80 review feedback + add skills-matcher tests - Add kwMatches() with \b word-boundary for ASCII keywords, substring for CJK - Remove ambiguous short keywords: "X", "RED", "TODO", "done", "doing", "todo" - Gate Layer-2 fallback on !forceAi so --force-ai reaches AI summary - Add EPIPE guard to handleAuto stdin-to-stdout path - Fix Layer-1 gate: strong-signal matching now works for any content length - Export kwMatches for unit testing - Add skills-matcher.test.ts with 39 tests covering kwMatches, strong-signal matching, false-positive prevention, --force-ai path, fallback, and reason output --- cli/src/__tests__/skills-matcher.test.ts | 501 +++++++++++++++++++++++ cli/src/skills-matcher.ts | 4 +- 2 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 cli/src/__tests__/skills-matcher.test.ts diff --git a/cli/src/__tests__/skills-matcher.test.ts b/cli/src/__tests__/skills-matcher.test.ts new file mode 100644 index 0000000..9b8474c --- /dev/null +++ b/cli/src/__tests__/skills-matcher.test.ts @@ -0,0 +1,501 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { kwMatches } from "../skills-matcher.js"; + +const { skillStore, invokeStore, setInvokeResult, resetStores } = vi.hoisted(() => { + const skillStore = { + templates: [ + { + id: "resume-modern", + zhName: "极简简历", + enName: "Modern Resume", + emoji: "📄", + description: "现代极简简历, A4 单页, 适合打印或导出 PDF", + category: "resume", + scenario: "personal", + aspectHint: "A4", + tags: ["resume", "cv", "简历"], + }, + { + id: "article-magazine", + zhName: "杂志文章", + enName: "Magazine Article", + emoji: "📖", + description: "Substack / Medium 高级感长文排版, 适合公众号、博客发布", + category: "article", + scenario: "marketing", + aspectHint: "A4 / 长页面", + tags: ["blog", "essay", "newsletter", "公众号", "博客", "文章"], + }, + { + id: "social-x-post-card", + zhName: "X (Twitter) 帖子卡", + enName: "X / Twitter Post Card", + emoji: "𝕏", + description: "拟真 X 推文卡片 + 互动数据, 适配视频叠加或图卡分享", + category: "card", + scenario: "marketing", + aspectHint: "1280×720 或 1080×1080", + tags: ["twitter", "x", "social", "card", "overlay"], + }, + { + id: "deck-swiss-international", + zhName: "瑞士国际主义 Deck", + enName: "Swiss International Deck", + emoji: "🇨🇭", + description: "Swiss International Style presentation, clean grids", + category: "slides", + scenario: "marketing", + aspectHint: "16:9", + tags: ["slides", "deck", "presentation", "swiss"], + }, + { + id: "pricing-page", + zhName: "定价页", + enName: "Pricing Page", + emoji: "💳", + description: "三档定价 + 特性对比表 + FAQ", + category: "prototype", + scenario: "sales", + aspectHint: "桌面 1440", + tags: ["pricing", "plans", "定价"], + }, + { + id: "team-okrs", + zhName: "团队 OKR 追踪", + enName: "Team OKRs", + emoji: "🎯", + description: "季度 banner + 3 个目标 + KR 进度条 + owner + 状态 pill", + category: "dashboard", + scenario: "product", + aspectHint: "桌面 1440", + tags: ["okr", "objectives", "key results", "目标"], + }, + { + id: "eng-runbook", + zhName: "工程 Runbook", + enName: "Engineering Runbook", + emoji: "📕", + description: "服务概述 + alerts 表 + dashboards + 操作命令 + on-call", + category: "doc", + scenario: "engineering", + aspectHint: "长页面", + tags: ["runbook", "ops", "oncall", "sre"], + }, + { + id: "kanban-board", + zhName: "看板", + enName: "Kanban Board", + emoji: "📋", + description: "Kanban board with columns", + category: "dashboard", + scenario: "operations", + aspectHint: "桌面 1440", + tags: ["kanban", "board", "todo", "doing", "done"], + }, + { + id: "weekly-update", + zhName: "团队周报 Deck", + enName: "Weekly Update Deck", + emoji: "🗓️", + description: "6-8 页横向滑动周报: 已发布 / 进行中 / 阻塞 / 指标 / 求助", + category: "slides", + scenario: "operations", + aspectHint: "16:9 ×8", + tags: ["weekly", "周报", "status"], + }, + { + id: "docs-page", + zhName: "技术文档页", + enName: "Documentation Page", + emoji: "📚", + description: "技术文档页, 带侧边栏导航", + category: "doc", + scenario: "engineering", + aspectHint: "桌面 1440", + tags: ["docs", "documentation", "doc"], + }, + ] as const, + }; + + const invokeStore = { + summaryText: "", + errorMessage: null as string | null, + }; + + const defaults = { + templates: JSON.parse(JSON.stringify(skillStore.templates)), + }; + + return { + skillStore, + invokeStore, + setInvokeResult: (summary: string, err?: string | null) => { + invokeStore.summaryText = summary; + invokeStore.errorMessage = err ?? null; + }, + resetStores: () => { + skillStore.templates = JSON.parse(JSON.stringify(defaults.templates)); + invokeStore.summaryText = ""; + invokeStore.errorMessage = null; + }, + }; +}); + +vi.mock("../skills-loader.js", () => ({ + listSkills: vi.fn(() => [...skillStore.templates]), + loadSkill: vi.fn((_dir: string, id: string) => { + const t = skillStore.templates.find((s) => s.id === id); + if (!t) return null; + return { ...t, body: "You are a world-class HTML designer. Output complete HTML.", exampleMd: undefined, exampleHtml: undefined }; + }), +})); + +vi.mock("../agents-invoke.js", () => ({ + invokeAgent: vi.fn(() => { + const chunks: any[] = []; + if (invokeStore.errorMessage) { + chunks.push({ type: "error", message: invokeStore.errorMessage }); + } else { + chunks.push({ type: "delta", text: invokeStore.summaryText }); + chunks.push({ type: "done", code: 0 }); + } + + let index = 0; + return { + getReader() { + return { + read() { + if (index < chunks.length) { + return Promise.resolve({ value: chunks[index++], done: false }); + } + return Promise.resolve({ value: undefined, done: true }); + }, + cancel() {}, + releaseLock() {}, + get closed() { + return index >= chunks.length; + }, + }; + }, + }; + }), +})); + +import { matchTemplate } from "../skills-matcher.js"; + +const SKILLS_DIR = "/fake/skills/dir"; +const AGENT_ID = "claude"; + +beforeEach(() => { + resetStores(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("kwMatches", () => { + it("matches ASCII keyword on word boundary", () => { + expect(kwMatches("I need a resume template", "resume")).toBe(true); + }); + + it("does NOT match ASCII keyword as substring inside another word", () => { + expect(kwMatches("the document is ready", "doc")).toBe(false); + }); + + it("matches standalone ASCII keyword at start of text", () => { + expect(kwMatches("doc is important", "doc")).toBe(true); + }); + + it("matches standalone ASCII keyword at end of text", () => { + expect(kwMatches("read the doc", "doc")).toBe(true); + }); + + it("does NOT match bare 'x' inside English words", () => { + expect(kwMatches("next example text", "x")).toBe(false); + }); + + it("does NOT match bare 'x' inside complex words", () => { + expect(kwMatches("experience index box max", "x")).toBe(false); + }); + + it("matches standalone 'x' as a word", () => { + expect(kwMatches("platform x is great", "x")).toBe(true); + }); + + it("does NOT match 'app' as substring in 'happy'", () => { + expect(kwMatches("happy developer", "app")).toBe(false); + }); + + it("does NOT match 'live' inside 'deliver'", () => { + expect(kwMatches("will deliver on time", "live")).toBe(false); + }); + + it("matches standalone 'live' as a word", () => { + expect(kwMatches("watch it live now", "live")).toBe(true); + }); + + it("does NOT match 'soft' inside 'software'", () => { + expect(kwMatches("software engineering", "soft")).toBe(false); + }); + + it("does NOT match 'red' inside 'required'", () => { + expect(kwMatches("required reading", "red")).toBe(false); + }); + + it("does NOT match 'done' as substring in 'abandoned'", () => { + expect(kwMatches("project abandoned", "done")).toBe(false); + }); + + it("matches standalone 'done' as a word", () => { + expect(kwMatches("task is done", "done")).toBe(true); + }); + + it("does NOT match 'todo' as substring in 'mastodon'", () => { + expect(kwMatches("use mastodon", "todo")).toBe(false); + }); + + it("uses substring matching for CJK characters", () => { + expect(kwMatches("这是一份简历内容", "简历")).toBe(true); + }); + + it("uses substring matching for mixed CJK in text", () => { + expect(kwMatches("关于定价方案", "定价")).toBe(true); + }); + + it("case-insensitive for ASCII keywords", () => { + expect(kwMatches("I need a RESUME", "resume")).toBe(true); + }); + + it("handles special regex characters in keyword safely", () => { + expect(kwMatches("this is 8-bit style", "8-bit")).toBe(true); + expect(kwMatches("use 8-bit encoding", "8-bit")).toBe(true); + }); +}); + +describe("matchTemplate strong-signal matching", () => { + it("matches Chinese resume content to resume-modern", async () => { + const result = await matchTemplate( + "简历\n工作经历: 某公司\n教育背景: 清华", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).toBe("resume-modern"); + }); + + it("matches English resume (CV) content to resume-modern", async () => { + const result = await matchTemplate( + "My CV\nWork Experience at Google\nEducation: MIT", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).toBe("resume-modern"); + }); + + it("matches pricing content to pricing-page", async () => { + const result = await matchTemplate( + "定价方案\n免费版: 100次\n专业版: 10000次", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).toBe("pricing-page"); + }); + + it("matches OKR content to team-okrs", async () => { + const result = await matchTemplate( + "Q1 OKRs\n目标1: 用户增长\n关键结果: DAU +50%", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).toBe("team-okrs"); + }); + + it("matches runbook content to eng-runbook", async () => { + const result = await matchTemplate( + "# Payment Service Runbook\nAlerts:\n- P0: 5xx > 1%\nOn-call: Alice", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).toBe("eng-runbook"); + }); +}); + +describe("matchTemplate word-boundary prevents false positives", () => { + it("does NOT match English text with 'x' to social-x-post-card", async () => { + const result = await matchTemplate( + "# Building Next-Generation Developer Tools\n\nOur team is exploring ways to enhance the developer experience with intelligent tooling that understands context and provides context-aware suggestions.\n\nKey features include fast text processing with minimal latency and excellent accuracy on complex tasks.", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).not.toBe("social-x-post-card"); + }); + + it("does NOT match content with 'document' word to docs-page via 'doc' substring", async () => { + const docsOnly = skillStore.templates.filter((t) => t.id === "docs-page"); + const result = await matchTemplate( + "this document is very long\nand has many sections", + docsOnly as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).toBe("docs-page"); + }); + + it("matches docs-page via full word 'documentation'", async () => { + const result = await matchTemplate( + "Project Documentation\n\nSetup\nUsage\nAPI Reference", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).toBe("docs-page"); + }); + + it("does NOT match 'app' substring in 'happy' to mobile-app", async () => { + const result = await matchTemplate( + "# Project Happy\n\nOur team is happy to announce the new release.", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).not.toBe("mobile-app"); + }); + + it("does NOT match 'live' inside 'deliver' to live-dashboard", async () => { + const result = await matchTemplate( + "# Delivery Process\n\nWe deliver projects on time with quality.", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).not.toBe("live-dashboard"); + }); + + it("does NOT match content with 'done' and 'doing' to kanban-board", async () => { + const only = skillStore.templates.filter((t) => t.id === "kanban-board"); + const result = await matchTemplate( + "I have done the work and am doing more.\nThe report is done.", + only as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).toBe("kanban-board"); + }); +}); + +describe("matchTemplate blog/article matching", () => { + it("matches blog content to article-magazine (contains 公众号, 写作)", async () => { + const result = await matchTemplate( + "# AI Agent 时代的文档写作\n\n在 AI agent 接管编码工作的今天,文档的最终产出格式应该是什么?\n\n## 实践建议\n\n- 使用 HTML Anything 一键转换\n- 导出到公众号、推特、知乎", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).toBe("article-magazine"); + }); +}); + +describe("matchTemplate fallback matching", () => { + it("falls through to deck-swiss-international for generic content", async () => { + const result = await matchTemplate( + "# Generic Project\n\nSome random text about things. Nothing specific.", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).toBe("deck-swiss-international"); + }); + + it("returns confidence <= 10", async () => { + const result = await matchTemplate( + "简历\n工作经历: ABC公司\n教育背景: 北京大学", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.confidence).toBeLessThanOrEqual(10); + }); +}); + +describe("matchTemplate short content handling", () => { + it("returns result even for very short content", async () => { + const result = await matchTemplate( + "hello", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).toBeTruthy(); + expect(result.confidence).toBeGreaterThanOrEqual(1); + }); + + it("returns result for empty content", async () => { + const result = await matchTemplate( + "", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.templateId).toBeTruthy(); + }); +}); + +describe("matchTemplate --force-ai", () => { + it("with --force-ai reaches AI summary instead of rule match", async () => { + setInvokeResult("技术博客"); + + const result = await matchTemplate( + "# AI Agent 时代的文档写作\n\n在 AI agent 接管编码工作的今天,文档的最终产出格式应该是什么?\n\n## 为什么 HTML 比 Markdown 更好\n\nMarkdown 是一个好的草稿格式,但它无法控制排版、色彩、动画和阅读体验。", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + true, + ); + + expect(result.templateId).toBeTruthy(); + expect(result.reason).toContain("最佳匹配:"); + expect(result.reason).not.toContain("关键词命中:"); + }); + + it("with --force-ai and AI error falls back to rule match", async () => { + setInvokeResult("", "Some error"); + + const result = await matchTemplate( + "简历\n工作经历: ABC公司\n教育背景: 北京大学", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + true, + ); + expect(result.templateId).toBe("resume-modern"); + }); +}); + +describe("matchTemplate reason output", () => { + it("includes keyword hits in strong-signal reason", async () => { + const result = await matchTemplate( + "个人简历\n\n工作经历: ABC科技有限公司, 高级前端工程师, 2021年至今\n教育背景: 北京大学, 计算机科学学士, 2014-2018", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.reason).toContain("关键词命中:"); + expect(result.reason).toContain("简历"); + }); + + it("includes zhName in result", async () => { + const result = await matchTemplate( + "简历\n工作经历: ABC公司", + skillStore.templates as any, + SKILLS_DIR, + AGENT_ID, + ); + expect(result.zhName).toBe("极简简历"); + }); +}); diff --git a/cli/src/skills-matcher.ts b/cli/src/skills-matcher.ts index 9c5d5cb..f4c9df1 100644 --- a/cli/src/skills-matcher.ts +++ b/cli/src/skills-matcher.ts @@ -113,7 +113,7 @@ function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function kwMatches(haystack: string, needle: string): boolean { +export function kwMatches(haystack: string, needle: string): boolean { const kw = needle.toLowerCase(); if (isAscii(kw)) { return new RegExp("\\b" + escapeRegExp(kw) + "\\b", "i").test(haystack); @@ -239,7 +239,7 @@ export async function matchTemplate( agentId: string, forceAi: boolean = false, ): Promise { - if (!forceAi && content.length >= MIN_CONTENT_LENGTH_FOR_AI) { + if (!forceAi) { const strong = strongSignalMatch(content, skillsDir); if (strong) { strong.confidence = Math.min(strong.confidence, 10);