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
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
你将从 0 开始,一步步做出一个可运行、可扩展、可发布的 Agent CLI,不只会“调用模型”,还会完整打通 REPL、Agentic Loop、上下文构建、工具系统和工程化发布。


---
------

## 教程结构(共 28 章)

Expand All @@ -18,14 +18,14 @@
第 6 部分:综合实战 (第 28 章)
```

---
------

## 第 0 章:开始之前

- 实现目标:明确教程定位、环境准备、项目结构和学习方式。
- 里程碑:`bun init -y && bun add @anthropic-ai/sdk ink react commander lodash-es`

---
------

## 第一部分:核心骨架

Expand Down Expand Up @@ -74,7 +74,7 @@
输入行/状态提示),支持流式文本渲染、工具进行中提示、基础键位交互
(如 `/clear`、`/help`、滚动或焦点切换预留)。

---
------

## 第二部分:工具系统

Expand Down Expand Up @@ -113,7 +113,7 @@
- 实现目标:实现 MCP 客户端并动态加载外部工具。
- 里程碑:`bun run src/index.ts "列出这个仓库最新的 5 个 PR"`

---
------

## 第三部分:高级特性

Expand Down Expand Up @@ -142,7 +142,7 @@
- 实现目标:实现 Pre/Post/Session 三级 Hook。
- 里程碑:工具执行后自动触发自定义命令(如 `git add`)。

---
------

## 第四部分:工程化

Expand All @@ -166,7 +166,7 @@
- 实现目标:支持本地打包、npm 发布、多平台分发。
- 里程碑:`npm i -g myagent && myagent --version`

---
------

## 第五部分:扩展生态

Expand All @@ -185,7 +185,7 @@
- 实现目标:统一 Provider 抽象,支持多云模型接入。
- 里程碑:切换环境变量即可切换 Provider。

---
------

## 第六部分:综合实战

Expand All @@ -194,7 +194,7 @@
- 实现目标:从空目录到可发布的 `myagent v1.0.0` 全流程演练。
- 里程碑:完成一次可复现的构建、测试、发布流程。

---
------

## 学习路径建议

Expand All @@ -203,22 +203,22 @@
- 生产可用:第 1-23 章
- 完整版本:全部 28 章

---
------

## 学习代码仓库地址

[hello-agent-cli 代码仓库](https://github.com/ricoNext/hello-agent-cli)

每章的代码按照分支存放在仓库中, 分支名称为 `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 章 |
Expand All @@ -243,7 +243,7 @@
### C. 学习路径建议

| 目标 | 需完成 | 耗时 |
| --- | --- | --- |
| ------ | ------ | ------ |
| 快速上手,能聊天 | 第 1-3 章 | 1-2 天 |
| 能干活的 Agent | 第 1-12 章 | 3-5 天 |
| 生产可用 | 第 1-23 章 | 2-3 周 |
Expand Down
2 changes: 1 addition & 1 deletion src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface RunAgentConversationOptions {
}

const BASE_SYSTEM =
"你是命令行里的编码助手。需要列文件、统计数量、跑测试时,优先用工具获取真实输出,不要编造结果。若用户明确要求「只转大小写、不访问磁盘」,优先使用 `uppercase` 工具";
"你是命令行里的编码助手。需要列文件、统计数量、跑测试时,优先用工具获取真实输出,不要编造结果。若用户明确要求「只转大小写、不访问磁盘」,优先使用 `uppercase` 工具。如果你需要修改文件,请先使用 `read_file` 工具读取文件,然后使用 `edit_file` 工具修改文件,最后使用 `write_file` 工具写入文件。";

let cachedSystemPrompt: string | null = null;

Expand Down
120 changes: 120 additions & 0 deletions src/tools/edit-file-tool.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
33 changes: 33 additions & 0 deletions src/tools/file-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { relative, resolve } from "node:path";

const readPaths = new Set<string>();

/** 相对路径相对于 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;
}
119 changes: 119 additions & 0 deletions src/tools/read-file-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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;
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;
},
};
Loading