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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
9 changes: 8 additions & 1 deletion src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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;
}
72 changes: 72 additions & 0 deletions src/tools/glob-tool.ts
Original file line number Diff line number Diff line change
@@ -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
);
},
};
Loading