Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<p align="center"><b>Live page:</b> <a href="https://open-design.ai/html-anything/"><b>open-design.ai/html-anything/</b></a> — overview, surface modes, and showcase before you clone.</p>

> **Markdown is the draft. HTML is what humans read. Your local agent writes it.** The agentic HTML editor — in the agentic era, you don't hand-edit docs anymore, so the output format should be what the reader actually wants: HTML. Local-first, zero API key, reuses the CLI session you already have logged in — **9 coding-agent CLIs** auto-detected on your `PATH` (Claude Code · Cursor Agent · Codex · Gemini CLI · GitHub Copilot CLI · OpenCode · Qwen Coder · Aider · IBM Bob), driven by **75 composable skill templates** across **9 deliverable surfaces** (magazine articles · keynote decks · résumés · posters · Xiaohongshu cards · tweet cards · web prototypes · data reports · Hyperframes videos). One-click export to WeChat / X / Zhihu, or download `.html` / `.png`.
> **Markdown is the draft. HTML is what humans read. Your local agent writes it.** The agentic HTML editor — in the agentic era, you don't hand-edit docs anymore, so the output format should be what the reader actually wants: HTML. Local-first, zero API key, reuses the CLI session you already have logged in — **10 coding-agent CLIs** auto-detected on your `PATH` (Claude Code · Cursor Agent · Codex · Gemini CLI · GitHub Copilot CLI · OpenCode · Qwen Coder · Aider · IBM Bob · Grok Build), driven by **75 composable skill templates** across **9 deliverable surfaces** (magazine articles · keynote decks · résumés · posters · Xiaohongshu cards · tweet cards · web prototypes · data reports · Hyperframes videos). One-click export to WeChat / X / Zhihu, or download `.html` / `.png`.

<p align="center">
<img src="docs/assets/banner.png" alt="HTML Anything — the agentic HTML editor, on your laptop" width="100%" />
Expand Down Expand Up @@ -203,10 +203,11 @@ On startup we scan `PATH` (including `~/.local/bin`, `~/.bun/bin`, `/opt/homebre
| **Qwen Coder** | `qwen` | `qwen --yolo -` |
| **Aider** | `aider` | `aider --no-pretty --no-stream --yes-always --message-file -` |
| **IBM Bob** | `bob` | `bob --output-format stream-json --hide-intermediary-output` |
| **Grok Build** | `grok` | `grok --no-auto-update --output-format streaming-json --always-approve --prompt-file <tmp>` |

> The detection strategy and per-CLI adapter shape are borrowed directly from [`nexu-io/open-design`](https://github.com/nexu-io/open-design) and [`multica-ai/multica`](https://github.com/multica-ai/multica): one privileged process spawns CLIs, JSON-line is the wire protocol, every CLI gets a thin adapter in [`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts).

If you've already done `claude login` / `cursor login` / `gemini auth` in your terminal, HTML Anything reuses that session. **No second copy of the API key required.**
If you've already done `claude login` / `cursor login` / `gemini auth` / `grok login` in your terminal, HTML Anything reuses that session. **No second copy of the API key required.**

## Skills

Expand Down Expand Up @@ -387,7 +388,7 @@ User-emitted HTML always renders inside `<iframe sandbox="allow-scripts allow-sa
│ Your local coding-agent CLI │
│ claude / codex / cursor-agent / │
│ gemini / copilot / opencode / │
│ qwen / aider │
│ qwen / aider / grok │
│ → reuses your existing session │
└────────────────────────────────────┘
│
Expand Down
5 changes: 3 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<p align="center"><b>项目主页:</b> <a href="https://open-design.ai/html-anything/"><b>open-design.ai/html-anything/</b></a> —— 不用 clone 也能先看看 HTML Anything 长什么样、能干啥。</p>

> **Markdown 是草稿, HTML 才是给人读的成品 —— 让本地 agent 直接写 HTML。** Agent 时代的 HTML 编辑器 —— 既然你已经不亲手改文档、全都让 Claude 改了, 那 agent 的输出就该是读者真正想看的 HTML, 而不是中间态的 markdown。本地优先、零 API Key、复用你已经登录好的 CLI session —— **9 个 coding-agent CLI** 在 `PATH` 上自动识别(Claude Code · Cursor Agent · Codex · Gemini CLI · GitHub Copilot CLI · OpenCode · Qwen Coder · Aider · IBM Bob),驱动 **75 套 skill 模板** 和 **9 类可交付场景**(杂志文章 · Keynote PPT · 简历 · 海报 · 小红书 · 推特卡 · Web 原型 · 数据报告 · Hyperframes 视频)。一键复制到公众号 / 推特 / 知乎,或者下载 `.html` / `.png`。
> **Markdown 是草稿, HTML 才是给人读的成品 —— 让本地 agent 直接写 HTML。** Agent 时代的 HTML 编辑器 —— 既然你已经不亲手改文档、全都让 Claude 改了, 那 agent 的输出就该是读者真正想看的 HTML, 而不是中间态的 markdown。本地优先、零 API Key、复用你已经登录好的 CLI session —— **10 个 coding-agent CLI** 在 `PATH` 上自动识别(Claude Code · Cursor Agent · Codex · Gemini CLI · GitHub Copilot CLI · OpenCode · Qwen Coder · Aider · IBM Bob · Grok Build),驱动 **75 套 skill 模板** 和 **9 类可交付场景**(杂志文章 · Keynote PPT · 简历 · 海报 · 小红书 · 推特卡 · Web 原型 · 数据报告 · Hyperframes 视频)。一键复制到公众号 / 推特 / 知乎,或者下载 `.html` / `.png`。

<p align="center">
<img src="docs/assets/banner.png" alt="HTML Anything — agent 时代的 HTML 编辑器,在你的笔记本上" width="100%" />
Expand Down Expand Up @@ -203,6 +203,7 @@ pnpm -F @html-anything/e2e test
| **Qwen Coder** | `qwen` | `qwen --yolo -` |
| **Aider** | `aider` | `aider --no-pretty --no-stream --yes-always --message-file -` |
| **IBM Bob** | `bob` | `bob --output-format stream-json --hide-intermediary-output` |
| **Grok Build** | `grok` | `grok --no-auto-update --output-format streaming-json --always-approve --prompt-file <tmp>` |

> 这一层的设计直接借鉴了 [`nexu-io/open-design`](https://github.com/nexu-io/open-design) 和 [`multica-ai/multica`](https://github.com/multica-ai/multica) 的 agent 检测策略:唯一被 spawn 子进程的进程是 server route,业务进程不直接 spawn;CLI 的 stdin / stdout 用 JSON-line 协议复用,每个 CLI 一个轻 adapter,统一在 [`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts)。

Expand Down Expand Up @@ -282,7 +283,7 @@ picker 用两个维度组织:
│ 你本地的 code agent │
│ claude / codex / cursor-agent / │
│ gemini / copilot / opencode / │
│ qwen / aider │
│ qwen / aider / grok │
│ → 复用你已登录的 session │
└────────────────────────────────────┘
│
Expand Down
1 change: 1 addition & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ html-anything 本身不做 AI 生成,它会自动检测并调用你系统里
| DeepSeek TUI | `npm install -g deepseek-tui` |
| Aider | `pip install aider-chat` |
| OpenClaw | 参考官方文档安装 |
| Grok Build | `npm install -g @xai-official/grok` |

## 常用模板推荐

Expand Down
32 changes: 32 additions & 0 deletions cli/src/__tests__/agents-detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ afterEach(() => {

describe("detectAgents", () => {
describe("*_BIN env override with absolute path that exists", () => {
it("finds grok via absolute GROK_BIN path", () => {
vi.stubEnv("GROK_BIN", "/usr/local/bin/grok");
existsSyncMock.mockImplementation(
(p) => p === "/usr/local/bin/grok",
);

const agents = detectAgents();
const grok = findAgent(agents, "grok");

expect(grok.available).toBe(true);
expect(grok.path).toBe("/usr/local/bin/grok");
expect(grok.resolvedBin).toBe("grok");
expect(grok.protocol).toBe("prompt-file");
});

it("finds claude via absolute CLAUDE_BIN path", () => {
vi.stubEnv("CLAUDE_BIN", "/usr/local/bin/claude");
existsSyncMock.mockImplementation(
Expand Down Expand Up @@ -94,6 +109,23 @@ describe("detectAgents", () => {
expect(claude.resolvedBin).toBe("claude");
});

it("detects grok on PATH as a prompt-file protocol agent", () => {
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/grok") return true;
return false;
});

const agents = detectAgents();
const grok = findAgent(agents, "grok");

expect(grok.available).toBe(true);
expect(grok.path).toBe("/usr/local/bin/grok");
expect(grok.resolvedBin).toBe("grok");
expect(grok.protocol).toBe("prompt-file");
expect(grok.unsupported).toBeUndefined();
expect(grok.label).toBe("Grok Build");
});

it("detects aider which has no envOverride via bin on PATH", () => {
existsSyncMock.mockImplementation((p) => {
if (p === "/usr/local/bin/aider") return true;
Expand Down
134 changes: 134 additions & 0 deletions cli/src/__tests__/agents-invoke.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { vi, describe, it, expect, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import { PassThrough, Writable } from "node:stream";

const { mockSpawn, existsSyncDelegate } = vi.hoisted(() => ({
Expand All @@ -18,6 +19,8 @@ vi.mock("node:fs", async () => {

import { invokeAgent, type InvokeEvent } from "../agents-invoke.js";

const realFs = await vi.importActual<typeof import("node:fs")>("node:fs");

function makeFakeChild() {
const stdout = new PassThrough();
const stderr = new PassThrough();
Expand Down Expand Up @@ -443,6 +446,137 @@ describe("invokeAgent", () => {
});
});

describe("grok headless contract", () => {
async function runGrok(prompt: string, signal?: AbortSignal) {
const { child, stdout } = makeFakeChild();
let spawned: { args: string[]; shell: unknown; promptPath: string; promptText: string } | undefined;
mockSpawn.mockImplementation((_bin: string, args: string[], spawnOpts: { shell?: unknown }) => {
const promptPath = args[args.indexOf("--prompt-file") + 1].replace(/^"|"$/g, "");
spawned = { args, shell: spawnOpts.shell, promptPath, promptText: readFileSync(promptPath, "utf8") };
return child;
});

const events = collectStream(
invokeAgent({ agent: "grok", prompt, model: "grok-build", binOverride: BIN_OVERRIDE, signal }),
);
await new Promise((r) => setTimeout(r, 0));
return {
spawned: spawned!,
finish: async () => {
stdout.end();
await new Promise((r) => setImmediate(r));
child.emit("close", 0);
return events;
},
};
}

it("passes the prompt through --prompt-file and removes the file on close", async () => {
const run = await runGrok("make a card");
const { spawned } = run;

expect(spawned.args).toEqual([
"--no-auto-update",
"--output-format",
"streaming-json",
"--always-approve",
"--model",
"grok-build",
"--prompt-file",
spawned.promptPath,
]);
expect(spawned.promptText).toBe("make a card");

await run.finish();
expect(realFs.existsSync(spawned.promptPath)).toBe(false);
});

it("keeps shell metacharacters out of the Windows command line", async () => {
const platform = process.platform;
Object.defineProperty(process, "platform", { value: "win32" });
const prompt = 'a & calc.exe | whoami > %TEMP%\\x.txt "%PATH%" ^ <b>';
try {
const run = await runGrok(prompt);
const { spawned } = run;

expect(spawned.shell).toBe(true);
// Node joins bin + args with spaces when shell is on; that string is
// what cmd.exe parses.
const commandLine = [BIN_OVERRIDE, ...spawned.args].join(" ");
for (const piece of ["&", "|", "%", "^", "<", ">", "calc", "whoami"]) {
expect(commandLine).not.toContain(piece);
}
expect(spawned.args.at(-1)).toBe(`"${spawned.promptPath}"`);
expect(spawned.promptText).toBe(prompt);
await run.finish();
} finally {
Object.defineProperty(process, "platform", { value: platform });
}
});

it.each(["grok", "claude"])(
"refuses a %s model carrying shell metacharacters before anything spawns",
async (agent) => {
const platform = process.platform;
Object.defineProperty(process, "platform", { value: "win32" });
try {
for (const model of [
"grok-build & calc.exe",
"x|whoami",
"%PATH%",
'a" "b',
"a^b",
"a>b",
"--config=evil",
"a b",
]) {
mockSpawn.mockClear();
const events = await collectStream(
invokeAgent({ agent, prompt: "make a card", model, binOverride: BIN_OVERRIDE }),
);
expect(events).toEqual([
{ type: "error", message: expect.stringContaining("invalid model id") },
]);
expect(mockSpawn).not.toHaveBeenCalled();
}
} finally {
Object.defineProperty(process, "platform", { value: platform });
}
},
);

it("removes the prompt file when the run is aborted", async () => {
const controller = new AbortController();
const run = await runGrok("make a card", controller.signal);
expect(realFs.existsSync(run.spawned.promptPath)).toBe(true);

controller.abort();
expect(realFs.existsSync(run.spawned.promptPath)).toBe(false);
});

it("parses streaming-json text and end events", async () => {
const events = await driveInvoke(
{ agent: "grok", prompt: "make a card", binOverride: BIN_OVERRIDE },
`${JSON.stringify({ type: "text", data: "<html>ok</html>" })}\n${JSON.stringify({
type: "end",
sessionId: "abc",
stopReason: "end_turn",
usage: { input_tokens: 3, output_tokens: 1 },
})}\n`,
0,
);

expect(events).toContainEqual({ type: "delta", text: "<html>ok</html>" });
expect(events).toContainEqual({ type: "meta", key: "session", value: "abc" });
expect(events).toContainEqual({ type: "meta", key: "result", value: "end_turn" });
expect(events).toContainEqual({
type: "meta",
key: "usage",
value: { input_tokens: 3, output_tokens: 1 },
});
});
});

describe("start event", () => {
it("includes bin, argv, and promptBytes", async () => {
const events = await driveInvoke(
Expand Down
14 changes: 13 additions & 1 deletion cli/src/agents-detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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 AgentProtocol = "stdin" | "argv" | "argv-message" | "prompt-file" | "acp" | "pi-rpc";

export type ModelOption = { id: string; label: string };

Expand Down Expand Up @@ -113,6 +113,18 @@ export const AGENTS: AgentDef[] = [
{ id: "gpt-5.2", label: "GPT-5.2" },
],
},
{
id: "grok",
label: "Grok Build",
bin: "grok",
envOverride: "GROK_BIN",
vendor: "xAI",
protocol: "prompt-file",
fallbackModels: [
DEFAULT_MODEL,
{ id: "grok-build", label: "grok-build" },
],
},
{
id: "opencode",
label: "OpenCode",
Expand Down
Loading