14days-build-claude-code-cli
Tutorial project for building a Claude Code-style agent CLI from scratch in Python with tool calling, file editing, and permission systems
How do I install this agent skill?
npx skills add https://github.com/reason-machines/devtools-skills --skill 14days-build-claude-code-cliIs this agent skill safe to install?
- Gen Agent Trust Hubwarn
This skill is an educational tutorial for building an AI agent CLI. It provides code snippets for shell command execution and instructions to download resources from an untrusted GitHub repository. These features introduce potential security surfaces, such as command injection through the bash tool and the execution of untrusted remote code.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
14days-build-claude-code-cli
Skill by ara.so — Devtools Skills collection.
A 14-day tutorial project for building a production-grade code agent CLI from scratch in Python. Learn to implement the "harness" layer around LLMs: tool calling, file operations, permission systems, session management, and agent orchestration patterns inspired by Claude Code.
What It Is
This is an educational implementation of a Code Agent CLI called agent-code. Over 14 days, you build:
- CLI runtime with REPL and slash commands
- Agent loop with tool calling (Anthropic Messages API format)
- File search, read, and safe edit with diff preview
- Bash command execution with permission engine
- Session persistence and project memory
- Hooks, skills, subagents, and MCP tool integration
- Worktree isolation for multi-task workflows
Default model: Uses DeepSeek's Anthropic-compatible endpoint for cost-effective testing, but works with any Anthropic Messages API compatible service (Claude, etc.)
Installation
Each day is a standalone Python package under packages/day-*/. For learning, you maintain one agent-code project and evolve it through 14 days.
Quick Start with a Completed Day
# Clone the repo
git clone https://github.com/bozhouDev/14days-build-claude-code-cli.git
cd 14days-build-claude-code-cli
# Run a completed day's snapshot
cd packages/day-02-real-model-tool-calling
uv sync
uv run agent-code "list files in current directory"
Set Up Your Own Learning Project
# Create your agent-code project
mkdir agent-code
cd agent-code
uv init
uv add typer anthropic
# Configure API keys (DeepSeek or Claude)
export ANTHROPIC_AUTH_TOKEN="sk-your-key"
export ANTHROPIC_BASE_URL="https://api.deepseek.com/anthropic"
Then follow tutorials starting from docs/day-01-hello-agent.md.
Key Architecture
# Core agent loop pattern (Day 2+)
from agent_code.providers import AnthropicProvider
from agent_code.tools import ToolRegistry
provider = AnthropicProvider()
registry = ToolRegistry()
registry.register_all()
messages = [{"role": "user", "content": user_input}]
while True:
response = provider.create_message(
model="deepseek-v4-flash",
messages=messages,
tools=registry.to_anthropic_format()
)
if response.stop_reason == "end_turn":
break
# Handle tool_use blocks
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = registry.execute(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
Day-by-Day Progression
Day 1: Hello Agent
CLI entry, REPL, mock provider, minimal agent loop.
# agent_code/cli.py
import typer
from agent_code.agent import Agent
from agent_code.providers import MockProvider
app = typer.Typer()
@app.command()
def main(prompt: str = None):
agent = Agent(MockProvider())
if prompt:
agent.run(prompt)
else:
agent.repl()
Day 2: Real Model + Tool Calling
Anthropic provider, tool_use / tool_result handling.
# agent_code/tools/echo.py
def echo_tool(message: str) -> str:
"""Echo a message back."""
return f"Echo: {message}"
ECHO_SCHEMA = {
"name": "echo",
"description": "Echo a message",
"input_schema": {
"type": "object",
"properties": {
"message": {"type": "string", "description": "Message to echo"}
},
"required": ["message"]
}
}
Day 3: File + Web Tools
File search, read with cwd boundary, web search tool.
# agent_code/tools/file_tools.py
import os
from pathlib import Path
def list_files_tool(path: str = ".") -> str:
"""List files in directory."""
abs_path = Path.cwd() / path
# Security: enforce cwd boundary
if not str(abs_path.resolve()).startswith(str(Path.cwd())):
return "Error: Path outside working directory"
files = [f.name for f in abs_path.iterdir()]
return "\n".join(files)
def read_file_tool(path: str) -> str:
"""Read file contents."""
abs_path = Path.cwd() / path
if not abs_path.exists():
return f"Error: File not found: {path}"
with open(abs_path, "r") as f:
return f.read()
Day 4: Safe Edit
Read-before-edit, string replacement, diff preview.
# agent_code/tools/edit_tools.py
import difflib
def write_file_tool(path: str, old_content: str, new_content: str) -> str:
"""Write file with diff preview and confirmation."""
abs_path = Path.cwd() / path
# Read current content
current = abs_path.read_text() if abs_path.exists() else ""
# Verify old_content matches (prevents concurrent edits)
if abs_path.exists() and old_content != current:
return "Error: File changed since read. Refresh and retry."
# Show diff
diff = "\n".join(difflib.unified_diff(
old_content.splitlines(),
new_content.splitlines(),
lineterm="",
fromfile=f"a/{path}",
tofile=f"b/{path}"
))
print(f"\n{diff}\n")
# Confirm
confirm = input("Apply? [y/N] ")
if confirm.lower() != "y":
return "Edit cancelled"
abs_path.write_text(new_content)
return f"Wrote {path}"
Day 5: Bash + Permission
Command execution with permission engine.
# agent_code/tools/bash_tool.py
import subprocess
from agent_code.permissions import check_permission
DANGEROUS_COMMANDS = ["rm -rf", "sudo", "mv /", "chmod 777"]
def bash_tool(command: str, background: bool = False) -> str:
"""Execute bash command."""
# Permission check
if any(danger in command for danger in DANGEROUS_COMMANDS):
if not check_permission(f"Run dangerous command: {command}"):
return "Permission denied"
if background:
subprocess.Popen(command, shell=True)
return f"Started in background: {command}"
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=30
)
output = result.stdout + result.stderr
return output or f"Exit code: {result.returncode}"
Day 6: Session + Memory
Session persistence, project memory in .memdir/.
# agent_code/session.py
import json
from pathlib import Path
from datetime import datetime
class SessionManager:
def __init__(self, session_dir: Path = None):
self.session_dir = session_dir or Path.cwd() / ".agent-sessions"
self.session_dir.mkdir(exist_ok=True)
def save_turn(self, session_id: str, user_msg: str, assistant_msg: str):
"""Append turn to session JSONL."""
session_file = self.session_dir / f"{session_id}.jsonl"
turn = {
"timestamp": datetime.utcnow().isoformat(),
"user": user_msg,
"assistant": assistant_msg
}
with open(session_file, "a") as f:
f.write(json.dumps(turn) + "\n")
def load_session(self, session_id: str) -> list:
"""Load session history."""
session_file = self.session_dir / f"{session_id}.jsonl"
if not session_file.exists():
return []
turns = []
with open(session_file, "r") as f:
for line in f:
turns.append(json.loads(line))
return turns
Day 7: Slash + Hooks
Slash commands (/help, /reset), hooks for tool execution.
# agent_code/slash.py
class SlashRouter:
def __init__(self):
self.commands = {}
def register(self, name: str, handler):
self.commands[name] = handler
def handle(self, input_text: str) -> bool:
"""Return True if handled as slash command."""
if not input_text.startswith("/"):
return False
parts = input_text[1:].split()
cmd = parts[0]
args = parts[1:]
if cmd in self.commands:
self.commands[cmd](*args)
return True
print(f"Unknown command: /{cmd}")
return True
# Usage in REPL
router = SlashRouter()
router.register("help", lambda: print("Available: /help, /reset, /exit"))
router.register("reset", lambda: messages.clear())
while True:
user_input = input("> ")
if router.handle(user_input):
continue
# Normal agent processing...
Configuration
Environment Variables
# API credentials (DeepSeek or Claude)
export ANTHROPIC_AUTH_TOKEN="sk-..."
export ANTHROPIC_BASE_URL="https://api.deepseek.com/anthropic"
# Model selection
export AGENT_MODEL="deepseek-v4-flash"
# Optional: session persistence location
export AGENT_SESSION_DIR=".agent-sessions"
Project Structure (Your Learning Project)
agent-code/
├── pyproject.toml
├── agent_code/
│ ├── __init__.py
│ ├── cli.py # Typer CLI entry
│ ├── agent.py # Agent loop
│ ├── providers.py # Mock + Anthropic providers
│ ├── tools/
│ │ ├── registry.py # Tool registry
│ │ ├── echo.py
│ │ ├── file_tools.py
│ │ ├── edit_tools.py
│ │ └── bash_tool.py
│ ├── permissions.py # Permission engine
│ ├── session.py # Session manager
│ └── slash.py # Slash command router
└── tests/
└── test_*.py
Running Tests
Each day's package includes tests. Always run from the specific day directory:
cd packages/day-05-bash-permission
uv run pytest
# Run specific test
uv run pytest tests/test_bash_tool.py -v
Common Patterns
Adding a Custom Tool
# 1. Define the function
def my_tool(arg1: str, arg2: int) -> str:
"""Tool description for the model."""
return f"Processed {arg1} with {arg2}"
# 2. Define Anthropic schema
MY_TOOL_SCHEMA = {
"name": "my_tool",
"description": "What this tool does",
"input_schema": {
"type": "object",
"properties": {
"arg1": {"type": "string", "description": "First argument"},
"arg2": {"type": "integer", "description": "Second argument"}
},
"required": ["arg1", "arg2"]
}
}
# 3. Register in ToolRegistry
registry.register("my_tool", my_tool, MY_TOOL_SCHEMA)
Implementing a Hook
# agent_code/hooks.py
class HookManager:
def __init__(self):
self.hooks = {"pre_tool": [], "post_tool": []}
def register(self, event: str, callback):
self.hooks[event].append(callback)
def trigger(self, event: str, **kwargs):
for callback in self.hooks.get(event, []):
callback(**kwargs)
# Usage
hooks = HookManager()
hooks.register("pre_tool", lambda tool_name, **kw: print(f"Calling {tool_name}"))
# In agent loop
hooks.trigger("pre_tool", tool_name=block.name, input=block.input)
result = registry.execute(block.name, block.input)
hooks.trigger("post_tool", tool_name=block.name, result=result)
Safe File Operations Pattern
Always follow this pattern for file edits:
# 1. Read current content
current_content = read_file_tool(path)
# 2. Let model see current content (include in prompt or tool result)
# 3. Model calls write_file_tool with old_content=current and new_content
# 4. In write_file_tool: verify old_content matches current
if old_content != current_actual:
return "File changed since read, refresh and retry"
# 5. Show diff and ask confirmation
# 6. Write only after approval
Troubleshooting
Tool Execution Fails Silently
Check tool result format matches Anthropic spec:
# Correct format
{
"type": "tool_result",
"tool_use_id": block.id, # Must match tool_use block
"content": result_string
}
Permission Denied on Bash Commands
The permission engine intercepts dangerous patterns. Either:
- Respond "y" to the permission prompt
- Adjust
DANGEROUS_COMMANDSlist inbash_tool.py - Implement a permission allowlist for trusted commands
DeepSeek Returns Malformed Tool Calls
DeepSeek's Anthropic compatibility is good but not perfect. If you see parsing errors:
# Add validation in agent loop
for block in response.content:
if block.type == "tool_use":
if not hasattr(block, "name") or not hasattr(block, "input"):
print(f"Warning: Malformed tool_use block: {block}")
continue
Session Files Growing Too Large
Implement context compaction (Day 11 topic):
def compact_session(messages, max_tokens=100000):
"""Keep system prompt, recent messages, summarize old ones."""
if total_tokens(messages) < max_tokens:
return messages
# Summarize middle messages
summary = model.create_message(
model="deepseek-v4-flash",
messages=[{"role": "user", "content": f"Summarize: {old_messages}"}]
)
return [messages[0]] + [summary] + messages[-10:]
Files Outside CWD Access Denied
This is by design (Day 3 security boundary). To allow broader access:
# Option 1: Expand allowed roots
ALLOWED_ROOTS = [Path.cwd(), Path.home() / "projects"]
# Option 2: Add a permission check instead of hard block
if not is_within_cwd(path):
if not check_permission(f"Access {path} outside project?"):
return "Permission denied"
Advanced Usage (Days 8-14)
Days 8-14 extend the foundation with:
- Day 8: Plan mode with task lists and execution constraints
- Day 9: Skills - on-demand knowledge loading
- Day 10: Subagents - delegate tasks to specialized agents
- Day 11: Context compaction for long conversations
- Day 12: Multi-agent coordination
- Day 13: Worktree isolation for parallel tasks
- Day 14: MCP client for tool ecosystem integration
Check the main repo for updated docs as these days are released.
Web Tutorial
Preview the interactive tutorial locally:
cd agent-code-learn
npm install
npm run dev
# Open http://localhost:3000
Production site: https://buildcc.dev
Learning Resources
- Tutorial docs:
docs/day-*.md- detailed step-by-step guides - Reference snapshots:
packages/day-*/- working code for each day - Architecture reference:
reference/claude-code-official/- public Claude Code snapshot for architecture study (not copied into tutorial)
Testing Your Implementation
# Test basic agent loop
uv run agent-code "echo hello using the echo tool"
# Test file operations
uv run agent-code "list files in current directory"
uv run agent-code "read pyproject.toml and summarize"
# Test bash (with permission prompt)
uv run agent-code "run 'ls -la' command"
# Test session persistence
uv run agent-code --session my-session "remember: my favorite color is blue"
uv run agent-code --session my-session "what's my favorite color?"
Project Goals
This is a teaching project. Goals:
- ✅ Understand agent harness architecture
- ✅ Learn tool calling patterns
- ✅ Implement safe file operations
- ✅ Build permission systems
- ✅ Manage agent context and memory
NOT goals:
- ❌ Production-ready robustness
- ❌ 1:1 Claude Code feature parity
- ❌ Enterprise-scale performance
Contributing
Issues and PRs welcome for:
- Tutorial clarity improvements
- Bug fixes in reference snapshots
- DeepSeek/Claude compatibility issues
- Documentation or translation fixes
- Architecture explanation corrections
GitHub: https://github.com/bozhouDev/14days-build-claude-code-cli
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/reason-machines/devtools-skills/14days-build-claude-code-cli">View 14days-build-claude-code-cli on skillZs</a>