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
|
pyyaml>=6.0.0
|
||||||
rich>=13.0.0
|
rich>=13.0.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
|
claude-agent-sdk
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user