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
5 changes: 4 additions & 1 deletion README-zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ def agent_loop(messages):

新读者请从根目录 `s01_agent_loop/` 读到 `s20_comprehensive/`。如果你是从旧链接或当前 Web 平台进入,大概率看到的是旧 12 章版本。旧版章节号和新版不完全一致,不要混用章节号。

每节课的 `code.py` 只保留本课新教的内容。所有课都重复的稳定样板 —— `readline`/`.env`/客户端初始化、基础文件工具(`bash`/`read_file`/`write_file`/`edit_file`/`glob`)、它们的 schema 以及标准 REPL —— 集中在仓库根目录的 [`common.py`](./common.py) 里,每课通过 `from common import ...` 引入。这样后期课程的文件只聚焦本课新增的机制,不再把已学过的代码重抄一遍。

### 旧版到新版的对应关系

| 旧 12 章版本 | 新 20 章版本 | 主题 |
Expand Down Expand Up @@ -383,11 +385,12 @@ flowchart TD

```
learn-claude-code/
common.py # 共享样板: 环境/客户端初始化、基础工具、schema、REPL
s01_agent_loop/ # 每章一个文件夹
README.md # 中文源文档(完整叙事)
README.en.md # 英文译本
README.ja.md # 日文译本
code.py # 独立可运行代码
code.py # 可运行代码(通过 from common import 引入共享样板)
images/ # SVG 流程图
s02_tool_use/
...
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,12 +339,14 @@ s08_context_compact/
README.md # full narrative with inline code
README.en.md # English translation
README.ja.md # Japanese translation
code.py # standalone runnable implementation
code.py # runnable implementation (imports shared boilerplate from common.py)
images/ # SVG diagrams (where needed)
```

Read the `README.md` for the core idea and work through the code. Complex chapters have `<details>` folds for deep dives -- open them when you want to go deeper. Simple chapters have 0-1 diagrams, complex chapters have more.

Each `code.py` only contains what that lesson teaches. The repeated boilerplate every chapter needs -- `readline`/`.env`/client setup, the base file tools (`bash`/`read_file`/`write_file`/`edit_file`/`glob`), their schemas, and the standard REPL -- lives in [`common.py`](./common.py) at the repo root and is pulled in with `from common import ...`. So as you read later chapters the file stays focused on the new mechanism, not the code you already learned.

Read from s01 through s20 in order. Each chapter assumes you've read the previous ones and ends with a hook into the next.

---
Expand Down Expand Up @@ -386,11 +388,12 @@ cd web && npm install && npm run dev # http://localhost:3000

```
learn-claude-code/
common.py # shared boilerplate: env/client init, base tools, schemas, REPL
s01_agent_loop/ # one folder per chapter
README.md # Chinese source (complete narrative)
README.en.md # English translation
README.ja.md # Japanese translation
code.py # standalone runnable code
code.py # runnable code (imports shared boilerplate from common.py)
images/ # SVG diagrams
s02_tool_use/
...
Expand Down
215 changes: 215 additions & 0 deletions common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
"""
common.py — shared foundation for every sNN lesson from s02 onward.

Each lesson's code.py imports its boilerplate and base tools from here, so the
lesson file only shows the NEW mechanism being taught (issue #349).

First-appearance rule: a concept is introduced INLINE in its origin lesson,
then abstracted into a shared module for later lessons. So s01 inlines
``init_env`` + ``run_repl`` (it introduces them); s02+ import them from here.
This file's ``init_env`` / ``run_repl`` mirror s01 — keep them in sync.
``make_base_tools`` mirrors the tool implementations s02 teaches inline.

What lives here:

- init_env(): readline + .env + Anthropic client + MODEL_ID + WORKDIR
- make_base_tools(wd): safe_path / run_bash / run_read / run_write / run_edit /
run_glob as closures bound to a workdir (same signatures
the lessons always used)
- BASE_TOOLS: the 5 base tool schemas (bash/read/write/edit/glob)
- select_tools(names): pick a subset of BASE_TOOLS by name
- run_repl(...): the standard CLI REPL used by every lesson's __main__

Notes:
- run_bash uses the utf-8-safe version (from s02) so non-ASCII output works on
Windows; the dangerous-command check is NOT here (s01 teaches it inline, s03+
handle it via permission/hooks).
- Lessons that modify a base tool signature (s13-s16 add run_in_background,
s18-s19 add cwd) re-define it locally after import and delegate to the base.
"""

import os
import subprocess
from pathlib import Path

# ── readline: UTF-8 backspace fix for macOS libedit; harmless elsewhere ──
try:
import readline
readline.parse_and_bind("set bind-tty-special-chars off")
readline.parse_and_bind("set input-meta on")
readline.parse_and_bind("set output-meta on")
readline.parse_and_bind("set convert-meta off")
except ImportError:
pass

from anthropic import Anthropic
from dotenv import load_dotenv


# ═══════════════════════════════════════════════════════════
# Environment / client init
# ═══════════════════════════════════════════════════════════

def init_env():
"""Load .env, build the Anthropic client, return (client, MODEL, WORKDIR).

Replaces the ~15 lines of boilerplate that used to open every lesson.
"""
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
WORKDIR = Path.cwd()
return client, MODEL, WORKDIR


# ═══════════════════════════════════════════════════════════
# Base tool implementations (canonical s02 versions, as closures over workdir)
# ═══════════════════════════════════════════════════════════

def make_base_tools(workdir: Path):
"""Return (safe_path, run_bash, run_read, run_write, run_edit, run_glob, BASE_TOOLS)
all bound to *workdir*.

The closures keep the original signatures (e.g. ``run_bash(command)``) so lesson
code reads exactly as before — the workdir is captured, not passed in.
"""
def safe_path(p: str) -> Path:
path = (workdir / p).resolve()
if not path.is_relative_to(workdir):
raise ValueError(f"Path escapes workspace: {p}")
return path

def run_bash(command: str) -> str:
try:
r = subprocess.run(command, shell=True, cwd=workdir,
capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
except (FileNotFoundError, OSError) as e:
return f"Error: {e}"

def run_read(path: str, limit: int | None = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"

def run_write(path: str, content: str) -> str:
try:
file_path = safe_path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"

def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
file_path = safe_path(path)
text = file_path.read_text()
if old_text not in text:
return f"Error: text not found in {path}"
file_path.write_text(text.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"

def run_glob(pattern: str) -> str:
import glob as g
try:
results = []
for match in g.glob(pattern, root_dir=workdir):
if (workdir / match).resolve().is_relative_to(workdir):
results.append(match)
return "\n".join(results) if results else "(no matches)"
except Exception as e:
return f"Error: {e}"

return safe_path, run_bash, run_read, run_write, run_edit, run_glob


# ═══════════════════════════════════════════════════════════
# Base tool schemas
# ═══════════════════════════════════════════════════════════

BASE_TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"limit": {"type": "integer"}},
"required": ["path"]}},
{"name": "write_file", "description": "Write content to a file.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"content": {"type": "string"}},
"required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"old_text": {"type": "string"},
"new_text": {"type": "string"}},
"required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
"input_schema": {"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"]}},
]

_TOOLS_BY_NAME = {t["name"]: t for t in BASE_TOOLS}


def select_tools(names):
"""Return the BASE_TOOLS entries matching *names*, in the order given."""
return [_TOOLS_BY_NAME[n] for n in names]


# ═══════════════════════════════════════════════════════════
# Standard REPL (replaces the __main__ block every lesson duplicated)
# ═══════════════════════════════════════════════════════════

def run_repl(prompt: str, banner: str, turn, context=None, on_submit=None,
hint: str = "输入问题,回车发送。输入 q 退出。\n"):
"""Run the lesson's CLI REPL.

Args:
prompt: the input() prompt, e.g. "\\033[36ms02 >> \\033[0m".
banner: title line printed once at startup.
turn: callable(history, context) -> optional new context. Called each
user turn to run the agent. Return None to keep context unchanged.
context: optional initial context passed to turn().
on_submit: optional callable(query) invoked before the user message is
appended (s04-s07 use it to fire UserPromptSubmit hooks).
hint: instruction line printed after the banner.
"""
print(banner)
print(hint)
history = []
while True:
try:
query = input(prompt)
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
if on_submit is not None:
on_submit(query)
history.append({"role": "user", "content": query})
new_ctx = turn(history, context)
if new_ctx is not None:
context = new_ctx
for block in history[-1]["content"]:
if getattr(block, "type", None) == "text":
print(block.text)
print()
Empty file added mechanisms/__init__.py
Empty file.
100 changes: 100 additions & 0 deletions mechanisms/background.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""
mechanisms/background.py — Background Tasks mechanism, sourced from s13 (origin).

First-appearance rule: s13 introduces this inline. s14-s16 reuse it verbatim
except for ``execute_tool`` (which is lesson-specific — the handler dict
differs per lesson). s20 carries a variant (``start_background_task(block,
handlers)`` accepts handlers as a parameter instead of a closure) and keeps
its own inline version.

Design — ``is_slow_operation`` and ``should_run_background`` are pure
module-level functions (no state). ``make_background(execute_fn)`` is a
factory that returns ``(start_background_task, collect_background_results,
has_pending_background)`` with closure-private state (counter + dicts + lock).
Each lesson passes its own ``execute_tool`` so the handler dict stays local.
"""

import threading


def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
"""Fallback heuristic: commands likely to take > 30s."""
if tool_name != "bash":
return False
cmd = tool_input.get("command", "").lower()
slow_keywords = ["install", "build", "test", "deploy", "compile",
"docker build", "pip install", "npm install",
"cargo build", "pytest", "make"]
return any(kw in cmd for kw in slow_keywords)


def should_run_background(tool_name: str, tool_input: dict) -> bool:
"""Model explicit request takes priority; fallback to heuristic."""
if tool_input.get("run_in_background"):
return True
return is_slow_operation(tool_name, tool_input)


def make_background(execute_fn):
"""Build background-task closures bound to *execute_fn*.

Args:
execute_fn: callable(block) -> str. Each lesson passes its own
``execute_tool`` which knows the lesson-specific handler dict.

Returns:
(start_background_task, collect_background_results, has_pending_background)
"""
state = {"counter": 0, "tasks": {}, "results": {}}
lock = threading.Lock()

def start_background_task(block) -> str:
"""Run tool in a daemon thread. Returns background task ID."""
state["counter"] += 1
bg_id = f"bg_{state['counter']:04d}"
cmd = block.input.get("command", block.name)

def worker():
result = execute_fn(block)
with lock:
state["tasks"][bg_id]["status"] = "completed"
state["results"][bg_id] = result

with lock:
state["tasks"][bg_id] = {
"tool_use_id": block.id,
"command": cmd,
"status": "running",
}
threading.Thread(target=worker, daemon=True).start()
print(f" \033[33m[background] dispatched {bg_id}: {cmd[:40]}\033[0m")
return bg_id

def collect_background_results() -> list[str]:
"""Collect completed background results as task_notification messages."""
with lock:
ready_ids = [bid for bid, task in state["tasks"].items()
if task["status"] == "completed"]
notifications = []
for bg_id in ready_ids:
with lock:
task = state["tasks"].pop(bg_id)
output = state["results"].pop(bg_id, "")
summary = output[:200] if len(output) > 200 else output
notifications.append(
f"<task_notification>\n"
f" <task_id>{bg_id}</task_id>\n"
f" <status>completed</status>\n"
f" <command>{task['command']}</command>\n"
f" <summary>{summary}</summary>\n"
f"</task_notification>")
print(f" \033[32m[background done] {bg_id}: "
f"{task['command'][:40]} ({len(output)} chars)\033[0m")
return notifications

def has_pending_background() -> bool:
"""Non-destructive: True if any completed task waits to be collected."""
with lock:
return any(t["status"] == "completed" for t in state["tasks"].values())

return start_background_task, collect_background_results, has_pending_background
Loading