diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3566beb --- /dev/null +++ b/CLAUDE.md @@ -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`. diff --git a/docs/claude/_sdk_test_common.py b/docs/claude/_sdk_test_common.py new file mode 100644 index 0000000..d2dc6db --- /dev/null +++ b/docs/claude/_sdk_test_common.py @@ -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) diff --git a/docs/claude-agent-sdk.md b/docs/claude/claude-agent-sdk.md similarity index 100% rename from docs/claude-agent-sdk.md rename to docs/claude/claude-agent-sdk.md diff --git a/docs/claude/test_client_write_resume.py b/docs/claude/test_client_write_resume.py new file mode 100644 index 0000000..e422145 --- /dev/null +++ b/docs/claude/test_client_write_resume.py @@ -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())) diff --git a/docs/claude/test_hooks_audit_deny.py b/docs/claude/test_hooks_audit_deny.py new file mode 100644 index 0000000..c573056 --- /dev/null +++ b/docs/claude/test_hooks_audit_deny.py @@ -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())) diff --git a/docs/claude/test_query_read_edit.py b/docs/claude/test_query_read_edit.py new file mode 100644 index 0000000..8612159 --- /dev/null +++ b/docs/claude/test_query_read_edit.py @@ -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())) diff --git a/requirements.txt b/requirements.txt index 73e75e2..e13a338 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ langchain-community>=0.2.0 pyyaml>=6.0.0 rich>=13.0.0 httpx>=0.27.0 +claude-agent-sdk