Skip to content

Commit 7ece408

Browse files
anthhubclaude
andcommitted
feat: Chapters 9-12 — CLI, commands, permissions, production features
Chapter 9 — Commander.js CLI: - Add cli.ts with --model, --max-tokens, --permission-mode, -p/--prompt - Interactive (REPL) and non-interactive (--prompt) modes - Update App.tsx and REPL.tsx to accept CLI props Chapter 10 — Slash commands & context compression: - Add commands/ with /help, /clear, /compact and command registry - Add services/compact/compact.ts with compactMessages(), estimateTokens() - Integrate command handling into REPL.tsx Chapter 11 — Interactive permission confirmation: - Add utils/interactive-permission.ts with terminal Y/n prompts - Wraps checkPermission() for interactive use Chapter 12 — Production-ready features: - Add utils/history.ts for session persistence (~/.mini-claude/sessions/) - Add utils/retry.ts with exponential backoff retry logic - Add utils/config.ts for multi-source config loading Documentation: - Add "Hands-on Build" sections to Ch9-12 docs (zh-CN and en) - Ch12 includes final project structure and completion message Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d8b5004 commit 7ece408

21 files changed

Lines changed: 1207 additions & 10 deletions

demo/cli.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* cli.ts - Commander.js CLI 入口
3+
*
4+
* 对应真实 Claude Code: src/entrypoints/cli.tsx
5+
* 解析命令行参数,决定运行模式,启动对应流程。
6+
*/
7+
8+
import { Command } from "commander";
9+
import { render } from "ink";
10+
import React from "react";
11+
import { App } from "./components/App.js";
12+
import { query } from "./query.js";
13+
import { allTools } from "./tools.js";
14+
import { createPermissionContext, createCheckPermissionFn } from "./utils/permissions.js";
15+
import { DEFAULT_MODEL, DEFAULT_CONFIG } from "./types/index.js";
16+
import type { PermissionMode } from "./types/index.js";
17+
18+
const program = new Command();
19+
20+
program
21+
.name("mini-claude")
22+
.description("A minimal Claude Code clone — built chapter by chapter")
23+
.version("0.1.0")
24+
.option("-m, --model <model>", "Model to use", DEFAULT_MODEL)
25+
.option("--max-tokens <n>", "Max output tokens", "4096")
26+
.option("--permission-mode <mode>", "Permission mode: default, auto, bypassPermissions", "auto")
27+
.option("-p, --prompt <text>", "Run a single prompt (non-interactive mode)")
28+
.option("--print", "Print response and exit (implies -p)")
29+
.action(async (options) => {
30+
const model = options.model;
31+
const maxTokens = parseInt(options.maxTokens, 10);
32+
const permMode = options.permissionMode as PermissionMode;
33+
34+
// 非交互模式:单次查询
35+
if (options.prompt || options.print) {
36+
const promptText = options.prompt ?? process.argv.slice(2).join(" ");
37+
if (!promptText) {
38+
console.error("Error: --prompt requires a text argument");
39+
process.exit(1);
40+
}
41+
42+
if (!process.env.ANTHROPIC_API_KEY) {
43+
console.error("Error: ANTHROPIC_API_KEY not set");
44+
process.exit(1);
45+
}
46+
47+
const permCtx = createPermissionContext(permMode);
48+
const checkPerm = createCheckPermissionFn(permCtx);
49+
50+
const result = await query(promptText, [], {
51+
model,
52+
maxTokens,
53+
checkPermission: checkPerm,
54+
onText: (text) => process.stdout.write(text),
55+
onToolUse: (name) => {
56+
if (!options.print) {
57+
process.stderr.write(`\n[tool: ${name}]\n`);
58+
}
59+
},
60+
});
61+
62+
if (!options.print) {
63+
console.error(`\n[${result.turns} turns, ${result.inputTokens}+${result.outputTokens} tokens]`);
64+
}
65+
process.exit(0);
66+
}
67+
68+
// 交互模式:启动 REPL
69+
render(React.createElement(App, { model, maxTokens, permissionMode: permMode }));
70+
});
71+
72+
program.parse();

demo/commands/clear.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* commands/clear.ts - /clear 命令
3+
*/
4+
export const clearCommand = {
5+
name: "clear",
6+
description: "清空对话历史",
7+
execute(): string {
8+
return "对话历史已清空。";
9+
},
10+
};

demo/commands/compact.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* commands/compact.ts - /compact 命令
3+
*
4+
* 对应真实 Claude Code 的上下文压缩功能。
5+
* 当对话历史过长时,用 AI 总结之前的对话,减少 token 使用。
6+
*/
7+
export const compactCommand = {
8+
name: "compact",
9+
description: "压缩对话上下文",
10+
execute(): string {
11+
return "上下文压缩完成。";
12+
},
13+
};

demo/commands/help.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* commands/help.ts - /help 命令
3+
*/
4+
export const helpCommand = {
5+
name: "help",
6+
description: "显示帮助信息",
7+
execute(): string {
8+
return `mini-claude 可用命令:
9+
/help — 显示此帮助信息
10+
/clear — 清空对话历史
11+
/compact — 压缩对话上下文
12+
/exit — 退出程序
13+
14+
可用工具: Echo, Read, Write, Edit, Bash, Grep, Glob`;
15+
},
16+
};

demo/commands/index.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* commands/index.ts - 命令注册表
3+
*
4+
* 对应真实 Claude Code: src/commands.ts
5+
* 管理所有斜杠命令的注册和查找。
6+
*/
7+
8+
import { helpCommand } from "./help.js";
9+
import { clearCommand } from "./clear.js";
10+
import { compactCommand } from "./compact.js";
11+
12+
export interface SlashCommand {
13+
name: string;
14+
description: string;
15+
execute(args?: string): string;
16+
}
17+
18+
export const commands: SlashCommand[] = [
19+
helpCommand,
20+
clearCommand,
21+
compactCommand,
22+
];
23+
24+
export function findCommand(name: string): SlashCommand | undefined {
25+
return commands.find((c) => c.name === name);
26+
}
27+
28+
/**
29+
* 检查输入是否为斜杠命令,如果是则执行
30+
* @returns 命令输出,或 null 表示不是命令
31+
*/
32+
export function tryExecuteCommand(input: string): string | null {
33+
if (!input.startsWith("/")) return null;
34+
35+
const parts = input.slice(1).split(/\s+/);
36+
const cmdName = parts[0];
37+
const args = parts.slice(1).join(" ");
38+
39+
const cmd = findCommand(cmdName);
40+
if (!cmd) return `未知命令: /${cmdName}。输入 /help 查看可用命令。`;
41+
42+
return cmd.execute(args);
43+
}

demo/components/App.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,15 @@
77
import React from "react";
88
import { Box, Text } from "ink";
99
import { REPL } from "../screens/REPL.js";
10+
import type { PermissionMode } from "../types/index.js";
1011

11-
export function App() {
12+
interface AppProps {
13+
model?: string;
14+
maxTokens?: number;
15+
permissionMode?: PermissionMode;
16+
}
17+
18+
export function App({ model, maxTokens, permissionMode }: AppProps) {
1219
if (!process.env.ANTHROPIC_API_KEY) {
1320
return (
1421
<Box flexDirection="column" padding={1}>
@@ -19,5 +26,5 @@ export function App() {
1926
);
2027
}
2128

22-
return <REPL />;
29+
return <REPL model={model} maxTokens={maxTokens} permissionMode={permissionMode} />;
2330
}

demo/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
"type": "module",
66
"scripts": {
77
"typecheck": "tsc --noEmit",
8-
"start": "bun run repl.tsx",
9-
"demo": "bun run main.ts"
8+
"start": "bun run cli.ts",
9+
"demo": "bun run main.ts",
10+
"repl": "bun run repl.tsx"
1011
},
1112
"dependencies": {
1213
"@anthropic-ai/sdk": "^0.52.0",
14+
"commander": "^14.0.3",
1315
"ink": "^6.8.0",
1416
"react": "^19.2.4"
1517
},

demo/screens/REPL.tsx

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,29 @@ import { query } from "../query.js";
1212
import { allTools } from "../tools.js";
1313
import { createPermissionContext, createCheckPermissionFn } from "../utils/permissions.js";
1414
import { DEFAULT_MODEL } from "../types/index.js";
15-
import type { Message } from "../types/index.js";
15+
import type { Message, PermissionMode } from "../types/index.js";
16+
import { tryExecuteCommand } from "../commands/index.js";
17+
import { compactMessages } from "../services/compact/compact.js";
18+
19+
interface REPLProps {
20+
model?: string;
21+
maxTokens?: number;
22+
permissionMode?: PermissionMode;
23+
}
1624

17-
export function REPL() {
25+
export function REPL({ model: modelProp, maxTokens: maxTokensProp, permissionMode: permModeProp }: REPLProps = {}) {
1826
const { exit } = useApp();
1927
const [messages, setMessages] = useState<Message[]>([]);
2028
const [input, setInput] = useState("");
2129
const [isLoading, setIsLoading] = useState(false);
2230
const [streamText, setStreamText] = useState("");
2331
const [tokenUsage, setTokenUsage] = useState({ input: 0, output: 0 });
2432

25-
const permCtx = createPermissionContext("auto");
33+
const activeModel = modelProp ?? DEFAULT_MODEL;
34+
const activeMaxTokens = maxTokensProp ?? 4096;
35+
const activePermMode = permModeProp ?? "auto";
36+
37+
const permCtx = createPermissionContext(activePermMode);
2638
const checkPerm = createCheckPermissionFn(permCtx);
2739

2840
const handleSubmit = useCallback(async (value: string) => {
@@ -35,14 +47,32 @@ export function REPL() {
3547
return;
3648
}
3749

50+
// 检查斜杠命令
51+
const cmdResult = tryExecuteCommand(trimmed);
52+
if (cmdResult !== null) {
53+
if (trimmed === "/clear") {
54+
setMessages([]);
55+
} else if (trimmed === "/compact") {
56+
setMessages((prev) => compactMessages(prev));
57+
}
58+
// 将命令结果作为系统消息显示
59+
setMessages((prev) => [...prev, {
60+
type: "system" as const,
61+
subtype: "local_command" as const,
62+
message: cmdResult,
63+
}]);
64+
setInput("");
65+
return;
66+
}
67+
3868
setInput("");
3969
setIsLoading(true);
4070
setStreamText("");
4171

4272
try {
4373
const result = await query(trimmed, [...messages], {
44-
model: DEFAULT_MODEL,
45-
maxTokens: 4096,
74+
model: activeModel,
75+
maxTokens: activeMaxTokens,
4676
checkPermission: checkPerm,
4777
onText: (text) => {
4878
setStreamText((prev) => prev + text);
@@ -74,7 +104,7 @@ export function REPL() {
74104
{/* Header */}
75105
<Box marginBottom={1}>
76106
<Text bold color="cyan">mini-claude</Text>
77-
<Text dimColor> | {DEFAULT_MODEL} | {allTools.length} tools | tokens: {tokenUsage.input}{tokenUsage.output}</Text>
107+
<Text dimColor> | {activeModel} | {allTools.length} tools | tokens: {tokenUsage.input}{tokenUsage.output}</Text>
78108
</Box>
79109

80110
{/* Message History */}

demo/services/compact/compact.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* services/compact/compact.ts - 上下文压缩
3+
*
4+
* 对应真实 Claude Code 的上下文压缩功能。
5+
* 当对话历史超过 token 限制时,用 AI 总结历史对话。
6+
*
7+
* 简化版:用截断替代 AI 总结(真实版本调用 API 做总结)。
8+
*/
9+
10+
import type { Message, SystemMessage } from "../../types/index.js";
11+
12+
/**
13+
* 压缩消息历史
14+
*
15+
* 策略:保留最近 N 条消息,之前的用摘要替代。
16+
* 真实 Claude Code 会调用 API 生成摘要。
17+
*/
18+
export function compactMessages(
19+
messages: Message[],
20+
keepRecent: number = 6
21+
): Message[] {
22+
if (messages.length <= keepRecent) return messages;
23+
24+
const removed = messages.length - keepRecent;
25+
const summary: SystemMessage = {
26+
type: "system",
27+
subtype: "compact_boundary",
28+
message: `[上下文已压缩:省略了前 ${removed} 条消息]`,
29+
};
30+
31+
return [summary, ...messages.slice(-keepRecent)];
32+
}
33+
34+
/**
35+
* 估算消息的 token 数(粗略)
36+
* 真实 Claude Code 使用 tiktoken 精确计算
37+
*/
38+
export function estimateTokens(messages: Message[]): number {
39+
let chars = 0;
40+
for (const msg of messages) {
41+
if (msg.type === "user") {
42+
const content = msg.message.content;
43+
if (typeof content === "string") chars += content.length;
44+
else chars += JSON.stringify(content).length;
45+
} else if (msg.type === "assistant") {
46+
chars += JSON.stringify(msg.message.content).length;
47+
}
48+
}
49+
return Math.ceil(chars / 4); // 粗略估算:4 字符 ≈ 1 token
50+
}

demo/utils/config.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* utils/config.ts - 配置加载
3+
*
4+
* 从多个来源加载配置(优先级从高到低):
5+
* 1. CLI 参数
6+
* 2. 环境变量
7+
* 3. ~/.mini-claude/config.json
8+
* 4. 默认值
9+
*/
10+
11+
import type { AppConfig } from "../types/index.js";
12+
import { DEFAULT_CONFIG } from "../types/index.js";
13+
import { existsSync } from "fs";
14+
import { join } from "path";
15+
16+
const CONFIG_PATH = join(process.env.HOME ?? ".", ".mini-claude", "config.json");
17+
18+
/**
19+
* 加载配置文件
20+
*/
21+
export async function loadConfigFile(): Promise<Partial<AppConfig>> {
22+
try {
23+
if (!existsSync(CONFIG_PATH)) return {};
24+
const file = Bun.file(CONFIG_PATH);
25+
return JSON.parse(await file.text());
26+
} catch {
27+
return {};
28+
}
29+
}
30+
31+
/**
32+
* 合并配置
33+
*/
34+
export function mergeConfig(
35+
cliOptions: Partial<AppConfig>,
36+
fileConfig: Partial<AppConfig>
37+
): AppConfig {
38+
return {
39+
apiKey: cliOptions.apiKey
40+
?? process.env.ANTHROPIC_API_KEY
41+
?? fileConfig.apiKey
42+
?? "",
43+
model: cliOptions.model ?? fileConfig.model ?? DEFAULT_CONFIG.model,
44+
maxTokens: cliOptions.maxTokens ?? fileConfig.maxTokens ?? DEFAULT_CONFIG.maxTokens,
45+
permissionMode: cliOptions.permissionMode ?? fileConfig.permissionMode ?? DEFAULT_CONFIG.permissionMode,
46+
cwd: cliOptions.cwd ?? DEFAULT_CONFIG.cwd,
47+
systemPrompt: cliOptions.systemPrompt ?? fileConfig.systemPrompt,
48+
};
49+
}

0 commit comments

Comments
 (0)