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:
parent
9c1fc7e5b8
commit
ba1b5b76c6
50
CLAUDE.md
Normal file
50
CLAUDE.md
Normal 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`.
|
||||
75
docs/claude/_sdk_test_common.py
Normal file
75
docs/claude/_sdk_test_common.py
Normal 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)
|
||||
116
docs/claude/test_client_write_resume.py
Normal file
116
docs/claude/test_client_write_resume.py
Normal 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()))
|
||||
111
docs/claude/test_hooks_audit_deny.py
Normal file
111
docs/claude/test_hooks_audit_deny.py
Normal 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()))
|
||||
96
docs/claude/test_query_read_edit.py
Normal file
96
docs/claude/test_query_read_edit.py
Normal 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()))
|
||||
@ -7,3 +7,4 @@ langchain-community>=0.2.0
|
||||
pyyaml>=6.0.0
|
||||
rich>=13.0.0
|
||||
httpx>=0.27.0
|
||||
claude-agent-sdk
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user