用 Claude Code 在终端里从零做一个命令行书签管理工具
bm:你写需求文档和项目规则CLAUDE.md,Claude Code 先出计划,再按子命令逐个写代码、写测试、跑测试。你负责审核每一处改动。
先看清成品
成品是
bm.py和test_bm.py,支持添加、列表、搜索、删除、导出 Markdown。本文附带的参考实现已在本机实际运行:3 个测试全部通过;添加重复网址提示“已存在”,导出按标签分组。Claude Code 2.1.306 已安装,命令行参数用--help核对过。本文环境没有 Claude 账号,没有实际让 Claude Code 写代码,它每次生成的代码会不同,参考实现只用来对照。
注意:Claude 只在 Anthropic 官方“支持的国家和地区”列表中提供,请在支持地区合规使用,不要用代理或他人账号绕过限制,也不要共享账号。遵守 Anthropic 的使用政策。价格和用量以官方价格页为准。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 需求文档 | SPEC.md,五个子命令 |
| 项目规则 | CLAUDE.md,Claude Code 启动时自动读取 |
| 书签工具 | 纯标准库 + SQLite |
| 测试 | pytest,用临时数据库 |
| 协作流程 | 计划模式 → 逐步实现 → 自己验证 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Claude 账号 | Pro 或更高订阅,或 Claude Console 的 API 额度 | 官方价格页显示免费计划不含 Claude Code |
| Node.js | 18 或更高 | 本文使用 22.23.3 |
| Claude Code | npm install -g @anthropic-ai/claude-code |
本文使用 2.1.306 |
| Python | 3.10 或更高 + pytest | |
| Git | 任意版本 | 每一步提交,方便回退 |
第一步:准备需求和规则
mkdir bm && cd bm && git init
1. SPEC.md
# 需求
做一个命令行书签工具 `bm`:
| 命令 | 作用 |
| :--- | :--- |
| `bm add URL [-t 标签,标签] [--title 标题]` | 添加书签;URL 必须以 http:// 或 https:// 开头;重复 URL 提示已存在 |
| `bm ls [-t 标签]` | 列出书签,按添加时间倒序,可按标签过滤 |
| `bm find 关键词` | 在标题和 URL 中搜索(不区分大小写) |
| `bm rm ID` | 删除 |
| `bm export` | 输出 Markdown 列表,按标签分组,无标签的归入“未分类” |
2. CLAUDE.md
# bm:命令行书签管理
## 技术约定
- Python 3.10+,只用标准库(argparse、sqlite3、pathlib、urllib);测试用 pytest
- 数据库默认 `~/.bm.db`,可用环境变量 `BM_DB` 覆盖(测试时用临时文件)
- 入口 `bm.py`,测试 `test_bm.py`;运行测试:`python -m pytest -q`
- 函数要有类型标注;代码注释和输出信息用简体中文
## 工作方式
- 先给计划,等我确认后再写代码
- 每完成一个子命令就运行测试
- 不要执行 `rm -rf`、`git push` 之类的命令
CLAUDE.md 是 Claude Code 的项目记忆文件,每次在这个目录启动都会读取。也可以在会话里输入 /init 让它生成初稿再修改。
git add . && git commit -m "需求和规则"
第二步:怎样和 Claude Code 协作?
1. 用计划模式启动
claude --permission-mode plan
计划模式下它只读不写。第一次运行会让你登录。粘贴:
阅读 SPEC.md 和 CLAUDE.md,给出实现计划:数据库表结构、每个子命令的函数、测试要覆盖哪些情况。先不要写代码。
检查计划里有没有这些点:URL 校验、重复网址、标签去重、按时间倒序、导出时“未分类”。缺了就让它补充。
2. 逐步实现
退出后用默认模式启动(改文件和执行命令都会先问你):
claude
按计划实现。顺序:先 add 和 ls,写对应测试并运行;通过后再做 find、rm、export。每做完一组停下来,告诉我测试结果。
每次它请求修改文件时,看 diff 再批准;请求执行命令时,看清命令再批准。
3. 每完成一组就提交
git add . && git commit -m "add 和 ls"
第三步:怎样验收?
1. 跑测试
python -m pytest -q
参考实现的实际输出:3 passed。Claude Code 写的测试数量会不同,关键是覆盖了需求表里的每一条。
2. 手动试用
export BM_DB=/tmp/demo.db
python bm.py add https://docs.python.org -t python,文档 --title "Python 文档"
python bm.py add https://developer.mozilla.org -t 前端
python bm.py add https://docs.python.org
python bm.py ls
python bm.py export
参考实现的实际输出:
已添加 #1
已添加 #2
已存在:https://docs.python.org
#2 https://developer.mozilla.org https://developer.mozilla.org [前端]
#1 Python 文档 https://docs.python.org [python,文档]
## python
- [Python 文档](https://docs.python.org)
...
3. 对照参考实现
"""bm:命令行书签管理。用法见 python bm.py -h"""
import argparse
import os
import sqlite3
import sys
from collections import defaultdict
from pathlib import Path
def db() -> sqlite3.Connection:
con = sqlite3.connect(os.environ.get("BM_DB", str(Path.home() / ".bm.db")))
con.execute("""CREATE TABLE IF NOT EXISTS bm (
id INTEGER PRIMARY KEY, url TEXT UNIQUE NOT NULL, title TEXT,
tags TEXT DEFAULT '', created TEXT DEFAULT CURRENT_TIMESTAMP)""")
return con
def norm_tags(s: str | None) -> str:
return ",".join(sorted({t.strip() for t in (s or "").split(",") if t.strip()}))
def add(url: str, tags: str | None = None, title: str | None = None) -> str:
if not url.startswith(("http://", "https://")):
return "错误:URL 必须以 http:// 或 https:// 开头"
with db() as con:
try:
cur = con.execute("INSERT INTO bm (url, title, tags) VALUES (?, ?, ?)", (url, title or url, norm_tags(tags)))
except sqlite3.IntegrityError:
return f"已存在:{url}"
return f"已添加 #{cur.lastrowid}"
def ls(tag: str | None = None) -> list[tuple]:
rows = db().execute("SELECT id, title, url, tags FROM bm ORDER BY id DESC").fetchall()
return [r for r in rows if not tag or tag in r[3].split(",")]
def find(kw: str) -> list[tuple]:
k = f"%{kw.lower()}%"
return db().execute("SELECT id, title, url, tags FROM bm WHERE lower(title) LIKE ? OR lower(url) LIKE ? ORDER BY id DESC", (k, k)).fetchall()
def rm(i: int) -> str:
with db() as con:
n = con.execute("DELETE FROM bm WHERE id = ?", (i,)).rowcount
return f"已删除 #{i}" if n else f"没有 #{i}"
def export() -> str:
groups: dict[str, list] = defaultdict(list)
for i, title, url, tags in db().execute("SELECT id, title, url, tags FROM bm ORDER BY id"):
for t in (tags.split(",") if tags else ["未分类"]):
groups[t].append(f"- [{title}]({url})")
keys = sorted(k for k in groups if k != "未分类") + (["未分类"] if "未分类" in groups else [])
return "\n\n".join(f"## {k}\n\n" + "\n".join(groups[k]) for k in keys) + "\n"
def show(rows: list[tuple]) -> None:
for i, title, url, tags in rows:
print(f"#{i} {title} {url}" + (f" [{tags}]" if tags else ""))
if not rows:
print("(没有书签)")
def main(argv: list[str] | None = None) -> None:
p = argparse.ArgumentParser(prog="bm", description="命令行书签管理")
sub = p.add_subparsers(dest="cmd", required=True)
a = sub.add_parser("add"); a.add_argument("url"); a.add_argument("-t", "--tags"); a.add_argument("--title")
l = sub.add_parser("ls"); l.add_argument("-t", "--tag")
f = sub.add_parser("find"); f.add_argument("kw")
r = sub.add_parser("rm"); r.add_argument("id", type=int)
sub.add_parser("export")
ns = p.parse_args(argv)
if ns.cmd == "add":
msg = add(ns.url, ns.tags, ns.title); print(msg)
if msg.startswith("错误"): sys.exit(1)
elif ns.cmd == "ls": show(ls(ns.tag))
elif ns.cmd == "find": show(find(ns.kw))
elif ns.cmd == "rm": print(rm(ns.id))
else: print(export(), end="")
if __name__ == "__main__":
main()
import pytest
import bm
@pytest.fixture(autouse=True)
def tmpdb(tmp_path, monkeypatch):
monkeypatch.setenv("BM_DB", str(tmp_path / "t.db"))
def test_add_validate_and_duplicate():
assert bm.add("ftp://x") .startswith("错误")
assert bm.add("https://a.com", "py, 工具 ,py") == "已添加 #1"
assert bm.add("https://a.com") == "已存在:https://a.com"
assert bm.ls()[0][3] == "py,工具"
def test_ls_filter_order_and_find():
bm.add("https://a.com", "py", "Python 官网")
bm.add("https://b.com", "js", "MDN")
assert [r[0] for r in bm.ls()] == [2, 1]
assert [r[0] for r in bm.ls("py")] == [1]
assert [r[0] for r in bm.find("python")] == [1]
assert [r[0] for r in bm.find("B.COM")] == [2]
def test_rm_and_export():
bm.add("https://a.com", "py", "A")
bm.add("https://c.com", None, "C")
assert bm.rm(9) == "没有 #9"
out = bm.export()
assert out.index("## py") < out.index("## 未分类")
assert "- [C](https://c.com)" in out
assert bm.rm(1) == "已删除 #1"
4. 非交互模式
-p 执行一次就退出,适合脚本:
git diff HEAD~1 | claude -p "审查这段改动,列出可能的 bug 和缺少的测试,用中文,不超过 10 条。"
出问题时该怎么调?
1. 它没按规则来
规则要具体、可检查,例如直接写出测试命令。可以在会话里提醒“请重新阅读 CLAUDE.md”。
2. 改坏了
git diff # 看改了什么
git checkout -- . # 放弃未提交的改动
3. 它想执行危险命令
拒绝。不要使用 --permission-mode bypassPermissions 跳过所有确认。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 登录后无法使用 | 免费计划不含 Claude Code | 订阅 Pro 或使用 API 额度 |
| 提示地区不支持 | 不在支持地区 | 只能在支持地区使用 |
| 测试用了真实数据库 | 没用 BM_DB |
在 CLAUDE.md 强调,并检查测试代码 |
| 一次改太多文件 | 任务太大 | 拆小,每次一个子命令 |
| 用量很快用完 | 长对话消耗大 | 每完成一组用新会话 |
项目资源
建议第一次严格按“计划 → 一组子命令 → 测试 → 提交”的节奏走完,熟悉每一步该检查什么。
之后再用同样的方法做你自己的小工具,需求文档写得越清楚,返工越少。