docs(claude): add SDK documentation and test examples

Add comprehensive documentation for Claude Agent SDK including usage patterns, test examples, and development guide. Includes:
- CLAUDE.md with project structure and quick reference
- SDK test examples for query, client, and hooks
- Shared test utilities for auth and tmpdir management
- Detailed SDK overview and capabilities documentation
This commit is contained in:
Yuyao Huang 2026-04-01 08:18:33 +08:00
parent 9c1fc7e5b8
commit ba1b5b76c6
7 changed files with 449 additions and 0 deletions

50
CLAUDE.md Normal file
View File

@ -0,0 +1,50 @@
# PhoneWork — Development Guide
## Quick Reference
| Need | Where to look |
|------|---------------|
| Project architecture, deployment, bot commands | `README.md` |
| Claude Agent SDK usage patterns and tested examples | `docs/claude/` |
| SDK migration plan (subprocess → ClaudeSDKClient) | `.claude/plans/toasty-pondering-nova.md` |
| Feishu card / markdown formatting | `docs/feishu/` |
## Claude Agent SDK
When writing code that uses `claude-agent-sdk`, **first read `docs/claude/`**:
- `_sdk_test_common.py` — .env auth loading pattern (`setup_auth()`, `auth_env()`, `make_tmpdir()`)
- `test_query_read_edit.py``query()` one-shot: Read + Edit with `allowed_tools`
- `test_client_write_resume.py``ClaudeSDKClient`: Write + session resume via `resume=session_id`
- `test_hooks_audit_deny.py` — Hooks: `PostToolUse` audit + `PreToolUse` deny
### Key rules (verified by testing)
- Use `allowed_tools` for permission auto-approval. **Do not pass custom lists to `tools=`** — it causes CLI exit code 1.
- Auth: set `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` in `.env`; pass via `ClaudeAgentOptions(env=auth_env())`. Clear `ANTHROPIC_API_KEY` to avoid auth conflicts.
- SDK does not strip ANSI escape codes from `ResultMessage.result` — handle in application layer if needed.
- On Windows, use manual `make_tmpdir()` / `remove_tmpdir()` instead of `tempfile.TemporaryDirectory()` context manager to avoid cleanup races with CLI subprocesses.
## Project Structure
```
bot/ Feishu event handling, commands, message sending
orchestrator/ LangChain agent + tools (session management, shell, files, web)
agent/ Claude Code runner, session manager, task runner, scheduler, audit
router/ Multi-host routing (public VPS side)
host_client/ Host client (behind NAT, connects to router)
shared/ Wire protocol for router ↔ host communication
docs/claude/ Claude Agent SDK examples and reference tests
docs/feishu/ Feishu API reference
```
## Running Tests
```bash
cd docs/claude
../../.venv/Scripts/python test_query_read_edit.py
../../.venv/Scripts/python test_client_write_resume.py
../../.venv/Scripts/python test_hooks_audit_deny.py
```
Requires `.env` at project root with `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN`.

View File

@ -0,0 +1,75 @@
"""Shared helpers for Claude Agent SDK tests.
Loads ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN from the project root .env
and injects them into the process environment and ClaudeAgentOptions.env.
"""
from __future__ import annotations
import os
import shutil
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DOTENV_PATH = PROJECT_ROOT / ".env"
def load_dotenv(dotenv_path: Path = DOTENV_PATH) -> dict[str, str]:
"""Parse KEY=VALUE lines from a .env file (no third-party deps)."""
values: dict[str, str] = {}
if not dotenv_path.exists():
return values
for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
return values
def setup_auth() -> tuple[bool, str]:
"""Inject .env auth vars into os.environ; clear ANTHROPIC_API_KEY to avoid conflicts."""
values = load_dotenv()
base_url = values.get("ANTHROPIC_BASE_URL")
auth_token = values.get("ANTHROPIC_AUTH_TOKEN")
oauth_token = values.get("CLAUDE_CODE_OAUTH_TOKEN")
if not base_url:
return False, "Missing ANTHROPIC_BASE_URL in .env"
if not auth_token and not oauth_token:
return False, "Missing ANTHROPIC_AUTH_TOKEN or CLAUDE_CODE_OAUTH_TOKEN in .env"
os.environ["ANTHROPIC_BASE_URL"] = base_url
if auth_token:
os.environ["ANTHROPIC_AUTH_TOKEN"] = auth_token
if oauth_token:
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
os.environ.pop("ANTHROPIC_API_KEY", None)
return True, f"Auth loaded from {DOTENV_PATH}"
def auth_env() -> dict[str, str]:
"""Return env dict suitable for ClaudeAgentOptions(env=...)."""
return {
"ANTHROPIC_BASE_URL": os.environ["ANTHROPIC_BASE_URL"],
"ANTHROPIC_AUTH_TOKEN": os.environ.get("ANTHROPIC_AUTH_TOKEN", ""),
"CLAUDE_CODE_OAUTH_TOKEN": os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", ""),
}
def make_tmpdir(prefix: str = "sdk-test-") -> Path:
"""Create a temporary directory manually (caller is responsible for cleanup)."""
return Path(tempfile.mkdtemp(prefix=prefix))
def remove_tmpdir(path: Path) -> None:
"""Remove a temporary directory, retrying once on Windows lock errors."""
try:
shutil.rmtree(path)
except OSError:
import time
time.sleep(1)
shutil.rmtree(path, ignore_errors=True)

View File

@ -0,0 +1,116 @@
"""Example: ClaudeSDKClient — Write file + resume session.
Demonstrates:
- ClaudeSDKClient (stateful, interactive mode)
- Creating a file via Write tool
- Capturing session_id from SystemMessage
- Resuming a session with options.resume
- receive_response() for streaming messages
- Manual tmpdir management
Usage:
cd docs/claude
../../.venv/Scripts/python test_client_write_resume.py
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from _sdk_test_common import auth_env, make_tmpdir, remove_tmpdir, setup_auth
async def drain(client):
"""Collect text from receive_response(), return (session_id, joined_text)."""
from claude_agent_sdk import AssistantMessage, ResultMessage, SystemMessage, TextBlock
texts: list[str] = []
session_id = None
async for msg in client.receive_response():
if isinstance(msg, SystemMessage) and msg.subtype == "init":
session_id = msg.data.get("session_id")
elif isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, TextBlock):
texts.append(block.text)
elif isinstance(msg, ResultMessage):
session_id = session_id or msg.session_id
if msg.result:
texts.append(msg.result)
return session_id, "\n".join(texts)
async def main() -> int:
ok, msg = setup_auth()
print(msg)
if not ok:
return 1
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
tmpdir = make_tmpdir("write-resume-")
try:
target = tmpdir / "session_test.txt"
# --- Turn 1: create file ---
print("--- Turn 1: Write ---")
async with ClaudeSDKClient(
ClaudeAgentOptions(
cwd=str(tmpdir),
allowed_tools=["Write", "Read"],
permission_mode="acceptEdits",
max_turns=3,
env=auth_env(),
)
) as client:
await client.query(
f"Use the Write tool to create session_test.txt "
f"in {tmpdir} with exactly the content 'Session 1'."
)
session_id, response = await drain(client)
print(f" Response: {response[:200]}")
if not target.exists():
print("FAIL: file not created")
return 2
print(f" File content: {target.read_text(encoding='utf-8')!r}")
print(f" Session ID: {session_id}")
if not session_id:
print("FAIL: no session_id captured")
return 3
# --- Turn 2: resume and verify context ---
print("\n--- Turn 2: Resume ---")
async with ClaudeSDKClient(
ClaudeAgentOptions(
cwd=str(tmpdir),
resume=session_id,
allowed_tools=["Read"],
permission_mode="acceptEdits",
max_turns=2,
env=auth_env(),
)
) as resumed:
await resumed.query(
"Without modifying files, tell me the exact filename and content you created."
)
_, resume_response = await drain(resumed)
print(f" Response: {resume_response[:300]}")
content_ok = target.read_text(encoding="utf-8").strip() == "Session 1"
resume_ok = "session_test.txt" in resume_response and "Session 1" in resume_response
if content_ok and resume_ok:
print("\nPASS")
return 0
print(f"\nFAIL: content_ok={content_ok} resume_ok={resume_ok}")
return 4
finally:
remove_tmpdir(tmpdir)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@ -0,0 +1,111 @@
"""Example: Hooks — audit log + PreToolUse deny.
Demonstrates:
- PostToolUse hook for audit logging
- PreToolUse hook to deny dangerous commands
- HookMatcher pattern matching by tool name
- Hook callback signature and return format
Usage:
cd docs/claude
../../.venv/Scripts/python test_hooks_audit_deny.py
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from _sdk_test_common import auth_env, make_tmpdir, remove_tmpdir, setup_auth
audit_log: list[dict] = []
async def audit_hook(input_data, tool_use_id, context):
"""PostToolUse: record every tool invocation."""
audit_log.append({
"tool": input_data.get("tool_name"),
"input": input_data.get("tool_input"),
})
return {}
async def deny_rm_hook(input_data, tool_use_id, context):
"""PreToolUse: block 'rm' commands."""
if input_data.get("tool_name") != "Bash":
return {}
command = input_data.get("tool_input", {}).get("command", "")
if "rm " in command:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "rm commands are blocked by policy",
}
}
return {}
async def main() -> int:
ok, msg = setup_auth()
print(msg)
if not ok:
return 1
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
HookMatcher,
ResultMessage,
TextBlock,
query,
)
tmpdir = make_tmpdir("hooks-")
try:
(tmpdir / "data.txt").write_text("important data\n", encoding="utf-8")
print("--- Query with hooks ---")
opts = ClaudeAgentOptions(
cwd=str(tmpdir),
allowed_tools=["Bash", "Read"],
permission_mode="acceptEdits",
max_turns=4,
env=auth_env(),
hooks={
"PostToolUse": [HookMatcher(matcher="Bash|Read", hooks=[audit_hook])],
"PreToolUse": [HookMatcher(matcher="Bash", hooks=[deny_rm_hook])],
},
)
async for msg in query(
prompt=(
"Do these steps in order:\n"
"1. Read data.txt\n"
"2. Run 'echo hello'\n"
"3. Run 'rm data.txt'\n"
"Report what happened for each step."
),
options=opts,
):
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, TextBlock):
print(f" Claude: {block.text[:300]}")
elif isinstance(msg, ResultMessage):
print(f" Result: {msg.result!r:.300}")
print(f"\nAudit log ({len(audit_log)} entries):")
for entry in audit_log:
print(f" {entry['tool']}: {entry['input']}")
file_exists = (tmpdir / "data.txt").exists()
print(f"\ndata.txt still exists: {file_exists}")
print("PASS" if file_exists else "FAIL: rm was not blocked")
return 0 if file_exists else 2
finally:
remove_tmpdir(tmpdir)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@ -0,0 +1,96 @@
"""Example: query() one-shot — Read and Edit files.
Demonstrates:
- .env-based auth (ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN)
- query() async iterator for simple one-shot tasks
- allowed_tools for auto-approval
- permission_mode="acceptEdits"
- Reading and editing a file in cwd
- Manual tmpdir management (avoids Windows cleanup races)
Usage:
cd docs/claude
../../.venv/Scripts/python test_query_read_edit.py
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from _sdk_test_common import auth_env, make_tmpdir, remove_tmpdir, setup_auth
async def main() -> int:
ok, msg = setup_auth()
print(msg)
if not ok:
return 1
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ResultMessage,
TextBlock,
ToolUseBlock,
query,
)
tmpdir = make_tmpdir("read-edit-")
try:
test_file = tmpdir / "hello.txt"
test_file.write_text("hello world\n", encoding="utf-8")
print(f"cwd: {tmpdir}")
# --- Step 1: Read ---
print("\n--- Read ---")
opts = ClaudeAgentOptions(
cwd=str(tmpdir),
allowed_tools=["Read"],
permission_mode="acceptEdits",
max_turns=2,
env=auth_env(),
)
async for msg in query(prompt="Read hello.txt and show its content.", options=opts):
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, ToolUseBlock):
print(f" ToolUse: {block.name}({block.input})")
elif isinstance(block, TextBlock):
print(f" Claude: {block.text[:200]}")
elif isinstance(msg, ResultMessage):
print(f" Result: {msg.result!r:.200}")
# --- Step 2: Edit ---
print("\n--- Edit ---")
opts = ClaudeAgentOptions(
cwd=str(tmpdir),
allowed_tools=["Read", "Edit"],
permission_mode="acceptEdits",
max_turns=3,
env=auth_env(),
)
async for msg in query(
prompt=f"Edit hello.txt in {tmpdir}. Add '# edited' as the first line.",
options=opts,
):
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, ToolUseBlock):
print(f" ToolUse: {block.name}({block.input})")
elif isinstance(block, TextBlock):
print(f" Claude: {block.text[:200]}")
elif isinstance(msg, ResultMessage):
print(f" Result: {msg.result!r:.200}")
content = test_file.read_text(encoding="utf-8")
print(f"\nFile after edit:\n{content}")
ok = "# edited" in content
print("PASS" if ok else "FAIL")
return 0 if ok else 2
finally:
remove_tmpdir(tmpdir)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@ -7,3 +7,4 @@ langchain-community>=0.2.0
pyyaml>=6.0.0
rich>=13.0.0
httpx>=0.27.0
claude-agent-sdk