diff --git a/README.md b/README.md index 4f32b44..de10738 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 你将从 0 开始,一步步做出一个可运行、可扩展、可发布的 Agent CLI,不只会“调用模型”,还会完整打通 REPL、Agentic Loop、上下文构建、工具系统和工程化发布。 ---- +------ ## 教程结构(共 28 章) @@ -18,14 +18,14 @@ 第 6 部分:综合实战 (第 28 章) ``` ---- +------ ## 第 0 章:开始之前 - 实现目标:明确教程定位、环境准备、项目结构和学习方式。 - 里程碑:`bun init -y && bun add @anthropic-ai/sdk ink react commander lodash-es` ---- +------ ## 第一部分:核心骨架 @@ -74,7 +74,7 @@ 输入行/状态提示),支持流式文本渲染、工具进行中提示、基础键位交互 (如 `/clear`、`/help`、滚动或焦点切换预留)。 ---- +------ ## 第二部分:工具系统 @@ -113,7 +113,7 @@ - 实现目标:实现 MCP 客户端并动态加载外部工具。 - 里程碑:`bun run src/index.ts "列出这个仓库最新的 5 个 PR"` ---- +------ ## 第三部分:高级特性 @@ -142,7 +142,7 @@ - 实现目标:实现 Pre/Post/Session 三级 Hook。 - 里程碑:工具执行后自动触发自定义命令(如 `git add`)。 ---- +------ ## 第四部分:工程化 @@ -166,7 +166,7 @@ - 实现目标:支持本地打包、npm 发布、多平台分发。 - 里程碑:`npm i -g myagent && myagent --version` ---- +------ ## 第五部分:扩展生态 @@ -185,7 +185,7 @@ - 实现目标:统一 Provider 抽象,支持多云模型接入。 - 里程碑:切换环境变量即可切换 Provider。 ---- +------ ## 第六部分:综合实战 @@ -194,7 +194,7 @@ - 实现目标:从空目录到可发布的 `myagent v1.0.0` 全流程演练。 - 里程碑:完成一次可复现的构建、测试、发布流程。 ---- +------ ## 学习路径建议 @@ -203,7 +203,7 @@ - 生产可用:第 1-23 章 - 完整版本:全部 28 章 ---- +------ ## 学习代码仓库地址 @@ -211,14 +211,14 @@ 每章的代码按照分支存放在仓库中, 分支名称为 `chapter-xxx`。 ---- +------ ### A. Claude Code 核心文件速查 [Claude Code 代码仓库](https://github.com/ricoNext/claude-code) | 文件 | 行数 | 对应章节 | -| --- | --- | --- | +| ------ | ------ | ------ | | `src/entrypoints/cli.tsx` | 320 | 第 2 章 | | `src/main.tsx` | 4,683 | 第 2 章 | | `src/screens/REPL.tsx` | 5,009 | 第 3 / 7 / 8 章 | @@ -243,7 +243,7 @@ ### C. 学习路径建议 | 目标 | 需完成 | 耗时 | -| --- | --- | --- | +| ------ | ------ | ------ | | 快速上手,能聊天 | 第 1-3 章 | 1-2 天 | | 能干活的 Agent | 第 1-12 章 | 3-5 天 | | 生产可用 | 第 1-23 章 | 2-3 周 | diff --git a/package.json b/package.json index cccfea1..b111cc4 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,11 @@ "build": "bun build src/index.ts --target node --outfile dist/index.js", "prepublishOnly": "bun run build", "check": "ultracite check", - "fix": "ultracite fix" + "fix": "ultracite fix", + "dev": "bun run src/index.ts" }, "type": "module", - "version": "0.3.1", + "version": "0.3.2", "engines": { "node": ">=24" } diff --git a/src/agent/loop.ts b/src/agent/loop.ts index 3eacbb7..507e663 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -19,7 +19,14 @@ export interface RunAgentConversationOptions { } const BASE_SYSTEM = - "你是命令行里的编码助手。需要列文件、统计数量、跑测试时,优先用工具获取真实输出,不要编造结果。若用户明确要求「只转大小写、不访问磁盘」,优先使用 `uppercase` 工具"; + "你是命令行里的编码助手。" + + "需要列文件、统计数量、跑测试时,优先用工具获取真实输出,不要编造结果。" + + "若用户明确要求「只转大小写、不访问磁盘」,优先使用 `uppercase` 工具。" + + "如果你需要修改文件,请先使用 `read_file` 工具读取文件,然后使用 `edit_file` 工具修改文件。" + + // 新增:搜索工具使用规范 + "查找文件名时使用 `glob` 工具(而非 bash find 或 ls);" + + "在文件内容中搜索时使用 `grep` 工具(而非 bash grep);" + + "运行测试、构建、git 操作等需要「执行」语义的任务才使用 `bash` 工具。"; let cachedSystemPrompt: string | null = null; diff --git a/src/tools/edit-file-tool.ts b/src/tools/edit-file-tool.ts new file mode 100644 index 0000000..9804d42 --- /dev/null +++ b/src/tools/edit-file-tool.ts @@ -0,0 +1,120 @@ +import fs from "node:fs/promises"; + +import { + assertPathInsideCwd, + markFileAsRead, + toWorkspaceAbsolutePath, + wasFileReadInSession, +} from "./file-session"; +import type { AgentTool } from "./types"; + +// 编辑文件工具 +export const editFileTool: AgentTool = { + name: "edit_file", + // 转换为 OpenAI 工具格式 + toOpenAI: () => ({ + type: "function", + function: { + name: "edit_file", + description: + "在已读取过的文本文件内做精确字符串替换。" + + "old_string 须唯一,除非 replace_all 为 true。" + + "不要包含 read_file 返回的行号前缀。", + parameters: { + type: "object", + properties: { + file_path: { type: "string" }, + old_string: { + type: "string", + description: "要被替换的原文(唯一匹配)", + }, + new_string: { type: "string", description: "替换后的内容" }, + replace_all: { + type: "boolean", + description: "为 true 时替换所有 old_string", + }, + }, + required: ["file_path", "old_string", "new_string"], + }, + }, + }), + // 执行工具 + async execute(args: unknown) { + // 解析参数 file_path, old_string, new_string, replace_all + const a = args as { + file_path?: unknown; + old_string?: unknown; + new_string?: unknown; + replace_all?: unknown; + }; + const filePath = typeof a.file_path === "string" ? a.file_path : ""; + const oldStr = typeof a.old_string === "string" ? a.old_string : ""; + const newStr = typeof a.new_string === "string" ? a.new_string : ""; + const replaceAll = a.replace_all === true; + if (!filePath.trim()) { + return "错误:file_path 为空"; + } + if (oldStr === newStr) { + return "错误:old_string 与 new_string 相同,无需修改"; + } + + // 转换为绝对路径 + const abs = toWorkspaceAbsolutePath(filePath); + // 断言路径是否在当前工作区之内 + const guard = assertPathInsideCwd(abs); + // 如果路径不在当前工作区之内,则返回错误 + if (guard) { + return guard; + } + // 如果文件未被读取,则返回错误 + if (!wasFileReadInSession(abs)) { + return "错误:请先用 read_file 读取该文件,再调用 edit_file"; + } + + // 读取文件 + let raw: string; + try { + raw = await fs.readFile(abs, "utf8"); + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return "错误:文件不存在"; + } + const msg = e instanceof Error ? e.message : String(e); + return `错误:读取失败 — ${msg}`; + } + // 如果 replace_all 为 true,则替换所有 old_string + if (replaceAll) { + if (!raw.includes(oldStr)) { + return "错误:找不到任何 old_string"; + } + // 替换所有 old_string + raw = raw.split(oldStr).join(newStr); + } else { + // 如果 replace_all 为 false,则替换第一个 old_string + const first = raw.indexOf(oldStr); + // 如果找不到 old_string,则返回错误 + if (first === -1) { + return "错误:找不到 old_string,请与磁盘一致(可重新 read_file)"; + } + // 查找第二个 old_string + const second = raw.indexOf(oldStr, first + oldStr.length); + // 如果找到第二个 old_string,则返回错误 + if (second !== -1) { + return ( + "错误:old_string 出现多次,请扩大上下文使片段唯一," + + "或设置 replace_all: true" + ); + } + // 得到新的文件内容 + raw = `${raw.slice(0, first)}${newStr}${raw.slice(first + oldStr.length)}`; + } + + // 写入文件 + await fs.writeFile(abs, raw, "utf8"); + // 标记文件为已读 + markFileAsRead(abs); + // 返回结果 + return JSON.stringify({ ok: true, path: abs }, null, 2); + }, +}; diff --git a/src/tools/file-session.ts b/src/tools/file-session.ts new file mode 100644 index 0000000..270bd7c --- /dev/null +++ b/src/tools/file-session.ts @@ -0,0 +1,33 @@ +import { relative, resolve } from "node:path"; + +const readPaths = new Set(); + +/** 相对路径相对于 process.cwd() */ +export function toWorkspaceAbsolutePath(filePath: string): string { + return resolve(process.cwd(), filePath); +} + +// 标记文件为已读 +export function markFileAsRead(absPath: string): void { + readPaths.add(absPath); +} + +// 清除文件的已读标记 +export function clearReadMark(absPath: string): void { + readPaths.delete(absPath); +} + +// 判断文件是否已读 +export function wasFileReadInSession(absPath: string): boolean { + return readPaths.has(absPath); +} + +// 断言路径是否在当前工作区之内 +export function assertPathInsideCwd(absPath: string): string | null { + const cwd = resolve(process.cwd()); + const rel = relative(cwd, absPath); + if (rel.startsWith("..")) { + return "错误:禁止访问工作区外的路径"; + } + return null; +} diff --git a/src/tools/glob-tool.ts b/src/tools/glob-tool.ts new file mode 100644 index 0000000..8d3eae2 --- /dev/null +++ b/src/tools/glob-tool.ts @@ -0,0 +1,72 @@ +import path from "node:path"; +import type { AgentTool } from "./types"; + +const MAX_FILES = 100; + +export const globTool: AgentTool = { + name: "glob", + toOpenAI: () => ({ + type: "function", + function: { + name: "glob", + description: + "按 glob 模式快速匹配工作区内的文件路径(如 '**/*.ts'、'src/**/*.tsx')。" + + "查找文件名时优先使用本工具,而非 bash find 或 ls。" + + "最多返回 100 条结果。", + parameters: { + type: "object", + properties: { + pattern: { + type: "string", + description: "glob 模式,如 '**/*.ts'、'src/**/*.{ts,tsx}'", + }, + path: { + type: "string", + description: "搜索根目录(相对或绝对路径),省略则为当前工作目录", + }, + }, + required: ["pattern"], + }, + }, + }), + async execute(args: unknown) { + // 解析参数 pattern, path + const a = args as { pattern?: unknown; path?: unknown }; + const pattern = typeof a.pattern === "string" ? a.pattern : ""; + if (!pattern.trim()) { + return "错误:pattern 为空"; + } + + // 搜索根目录:有传 path 就 resolve,否则用 cwd + const root = + typeof a.path === "string" && a.path.trim() + ? path.resolve(process.cwd(), a.path) + : process.cwd(); + + // 使用 Bun 内建 Glob,scan 返回相对于 cwd 的路径 + const glob = new Bun.Glob(pattern); + const files: string[] = []; + let truncated = false; + + for await (const file of glob.scan({ cwd: root })) { + if (files.length >= MAX_FILES) { + truncated = true; + break; + } + files.push(file); + } + + if (files.length === 0) { + return JSON.stringify({ files: [], numFiles: 0, truncated: false }); + } + + // 字典序排序,结果稳定 + files.sort(); + + return JSON.stringify( + { files, numFiles: files.length, truncated }, + null, + 2 + ); + }, +}; diff --git a/src/tools/grep-tool.ts b/src/tools/grep-tool.ts new file mode 100644 index 0000000..acbc7b1 --- /dev/null +++ b/src/tools/grep-tool.ts @@ -0,0 +1,104 @@ +import path from "node:path"; +import type { AgentTool } from "./types"; + +const MAX_LINES = 250; + +export const grepTool: AgentTool = { + name: "grep", + toOpenAI: () => ({ + type: "function", + function: { + name: "grep", + description: + "在文件内容中搜索正则表达式,返回匹配行(带行号)。" + + "搜索代码内容时优先使用本工具,而非 bash grep 或 bash rg。" + + "支持递归搜索、文件类型过滤、大小写忽略。", + parameters: { + type: "object", + properties: { + pattern: { + type: "string", + description: "正则表达式,如 'function\\\\s+\\\\w+' 或 'TODO'", + }, + path: { + type: "string", + description: + "搜索目录或文件(相对或绝对路径),省略则为当前工作目录", + }, + glob: { + type: "string", + description: "按文件名过滤,如 '*.ts'、'*.{ts,tsx}'", + }, + case_insensitive: { + type: "boolean", + description: "是否忽略大小写,默认 false", + }, + }, + required: ["pattern"], + }, + }, + }), + async execute(args: unknown) { + // 解析参数 pattern, path, glob, case_insensitive + const a = args as { + pattern?: unknown; + path?: unknown; + glob?: unknown; + case_insensitive?: unknown; + }; + const pattern = typeof a.pattern === "string" ? a.pattern : ""; + if (!pattern.trim()) { + return "错误:pattern 为空"; + } + + // 搜索根目录 + const searchPath = + typeof a.path === "string" && a.path.trim() + ? path.resolve(process.cwd(), a.path) + : process.cwd(); + // 解析 glob 模式 + const globPattern = typeof a.glob === "string" ? a.glob.trim() : ""; + // 解析是否忽略大小写 + const caseInsensitive = a.case_insensitive === true; + + // 构建 grep 参数 + // -r: 递归 -n: 显示行号 --include: 文件过滤 --exclude-dir: 排除目录 + const grepArgs: string[] = ["-r", "-n", "--exclude-dir=.git"]; + if (caseInsensitive) { + grepArgs.push("-i"); + } + if (globPattern) { + // glob 模式作为 --include 传入(如 "*.ts" → --include=*.ts) + grepArgs.push(`--include=${globPattern}`); + } + // pattern 和搜索路径放最后 + grepArgs.push(pattern, searchPath); + + // 使用 Bun.spawn 执行,与 bash.ts 的 executeBash 保持一致 + const proc = Bun.spawn(["grep", ...grepArgs], { + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + + if (exitCode > 1) { + // grep 约定:exitCode 0 = 有匹配,1 = 无匹配,>1 = 出错 + return `错误:grep 执行失败\n${stderr.trim()}`; + } + if (!stdout.trim()) { + return "未找到匹配内容"; + } + + // 截断输出,防止撑爆上下文 + const lines = stdout.split("\n").filter(Boolean); + const truncated = lines.length > MAX_LINES; + const output = lines.slice(0, MAX_LINES).join("\n"); + + return truncated + ? `${output}\n…(截断,共 ${lines.length} 行,可缩小搜索范围)` + : output; + }, +}; diff --git a/src/tools/read-file-tool.ts b/src/tools/read-file-tool.ts new file mode 100644 index 0000000..8e0974a --- /dev/null +++ b/src/tools/read-file-tool.ts @@ -0,0 +1,121 @@ +import fs from "node:fs/promises"; + +import { + assertPathInsideCwd, + markFileAsRead, + toWorkspaceAbsolutePath, +} from "./file-session"; + +import type { AgentTool } from "./types"; + +// 行分隔符正则表达式 +const LINE_ENDING_REGEXP = /\r?\n/; + +// 读取文件工具 +export const readFileTool: AgentTool = { + name: "read_file", + // 转换为 OpenAI 工具格式 + toOpenAI: () => ({ + type: "function", + function: { + name: "read_file", + description: + "读取工作区内文本文件。返回带行前缀的行文本,便于 edit_file 精确匹配。" + + "可选 offset/limit。", + parameters: { + type: "object", + properties: { + file_path: { + type: "string", + description: "相对或绝对路径(相对则相对当前工作目录)", + }, + offset: { + type: "integer", + description: "起始行号(从 1 开始)。省略则从文件开头读。", + }, + limit: { + type: "integer", + description: "最多读取行数。省略则读到末尾(受 maxLines 截断)。", + }, + }, + required: ["file_path"], + }, + }, + }), + // 执行工具 + async execute(args: unknown) { + // 解析参数 file_path, offset, limit + const a = args as { + file_path?: unknown; + offset?: unknown; + limit?: unknown; + }; + const filePath = typeof a.file_path === "string" ? a.file_path : ""; + if (!filePath.trim()) { + return "错误:file_path 为空"; + } + // 转换为绝对路径 + const abs = toWorkspaceAbsolutePath(filePath); + // 断言路径是否在当前工作区之内 + const guard = assertPathInsideCwd(abs); + if (guard) { + return guard; + } + // 读取文件最大行数 + const maxLines = 2000; + + // 解析起始行号:省略则从文件开头读。 + let offset = + typeof a.offset === "number" && Number.isFinite(a.offset) + ? Math.trunc(a.offset) + : 1; + // 解析最多读取行数:省略则读到末尾(受 maxLines 截断)。 + let limit = + typeof a.limit === "number" && Number.isFinite(a.limit) + ? Math.trunc(a.limit) + : maxLines; + + // 如果起始行号小于 1,则从文件开头读。 + if (offset < 1) { + offset = 1; + } + // 如果最多读取行数小于 1,则读到末尾。 + if (limit < 1) { + limit = maxLines; + } + + // 读取文件 + let raw: string; + try { + raw = await fs.readFile(abs, "utf8"); + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return "错误:文件不存在"; + } + const msg = e instanceof Error ? e.message : String(e); + return `错误:读取失败 — ${msg}`; + } + + // 按行分隔 + const lines = raw.split(LINE_ENDING_REGEXP); + // 截取指定行数 + const slice = lines.slice(offset - 1, offset - 1 + limit); + // 格式化输出 + const out = slice + .map((line, i) => { + const n = offset + i; + // 格式化行号,使用 padStart 方法,保证行号长度为 6,不足时用空格填充 + // 然后拼接行号和行内容,使用 → 分隔 + return `${String(n).padStart(6, " ")}→${line}`; + }) + .join("\n"); + + // 标记文件为已读 + markFileAsRead(abs); + // 截取最大内容: 取 10⁵ 量级 比较常见(约几十到一百多 KB 量级的 UTF-8 文本) + const cap = 120_000; + // 返回结果 + return out.length > cap ? `${out.slice(0, cap)}\n...(truncated)` : out; + }, +}; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 356b80e..cff2e2a 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -1,10 +1,23 @@ import type { ChatCompletionTool } from "openai/resources/chat/completions"; import { bashTool } from "./bash-tool"; +import { editFileTool } from "./edit-file-tool"; +import { globTool } from "./glob-tool"; +import { grepTool } from "./grep-tool"; +import { readFileTool } from "./read-file-tool"; import type { AgentTool } from "./types"; import { uppercaseTool } from "./uppercase-tool"; +import { writeFileTool } from "./write-file-tool"; // 工具列表 -export const AGENT_TOOLS: readonly AgentTool[] = [bashTool, uppercaseTool]; +export const AGENT_TOOLS: readonly AgentTool[] = [ + bashTool, + uppercaseTool, + editFileTool, + readFileTool, + writeFileTool, + globTool, + grepTool, +]; // 工具名称匹配工具 export function toolMatchesName( diff --git a/src/tools/write-file-tool.ts b/src/tools/write-file-tool.ts new file mode 100644 index 0000000..d59f463 --- /dev/null +++ b/src/tools/write-file-tool.ts @@ -0,0 +1,82 @@ +import fs from "node:fs/promises"; +import { dirname } from "node:path"; + +import { + assertPathInsideCwd, + clearReadMark, + toWorkspaceAbsolutePath, + wasFileReadInSession, +} from "./file-session"; +import type { AgentTool } from "./types"; + +export const writeFileTool: AgentTool = { + name: "write_file", + toOpenAI: () => ({ + type: "function", + function: { + name: "write_file", + description: + "向工作区写入文本(整文件覆盖)。若路径已存在须先 read_file;" + + "新建可直接写入。大段修改更推荐 edit_file。", + parameters: { + type: "object", + properties: { + file_path: { type: "string", description: "目标路径(相对或绝对)" }, + content: { type: "string", description: "完整文件内容" }, + }, + required: ["file_path", "content"], + }, + }, + }), + // 执行工具 + async execute(args: unknown) { + // 解析参数 file_path, content + const a = args as { file_path?: unknown; content?: unknown }; + // 解析文件路径 + const filePath = typeof a.file_path === "string" ? a.file_path : ""; + // 解析文件内容 + const content = typeof a.content === "string" ? a.content : ""; + // 如果文件路径为空,则返回错误 + if (!filePath.trim()) { + return "错误:file_path 为空"; + } + // 转换为绝对路径 + const abs = toWorkspaceAbsolutePath(filePath); + // 断言路径是否在当前工作区之内 + const guard = assertPathInsideCwd(abs); + // 如果路径不在当前工作区之内,则返回错误 + if (guard) { + return guard; + } + + // 检查文件是否存在 + let existed = false; + // 尝试访问文件 + try { + await fs.access(abs); + existed = true; + } catch { + existed = false; + } + + // 如果文件存在且未被读取,则返回错误 + // 这个错误主要是用来告诉模型,文件如果存在需要先调用 read_file 读取后再 write_file + if (existed && !wasFileReadInSession(abs)) { + return ( + "错误:目标文件已存在,请先用 read_file 读取后再 write_file" + + "(与先读后写策略一致)" + ); + } + + // 创建目录, 如果目录不存在则创建, 存在则忽略 + await fs.mkdir(dirname(abs), { recursive: true }); + // 写入文件 + await fs.writeFile(abs, content, "utf8"); + // 清除文件的已读标记 + clearReadMark(abs); + // 计算文件字节数 + const bytes = Buffer.byteLength(content, "utf8"); + // 返回结果 + return JSON.stringify({ ok: true, path: abs, bytes }, null, 2); + }, +};