开发技巧

用 Claude Code 在终端里从零做一个命令行书签管理工具 bm

用 Claude Code 在终端里从零做一个命令行书签管理工具 bm:你写需求文档和项目规则 CLAUDE.md,Claude Code 先出计划,再按子命令逐个写代码、写测试、跑测试。你负责审核每一处改动。

BINBHO · 图文教程

用 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 强调,并检查测试代码
一次改太多文件 任务太大 拆小,每次一个子命令
用量很快用完 长对话消耗大 每完成一组用新会话

项目资源

用途 地址
Claude Code 概览 https://docs.claude.com/en/docs/claude-code/overview
CLAUDE.md 记忆文件 https://docs.claude.com/en/docs/claude-code/memory
命令行参数 https://docs.claude.com/en/docs/claude-code/cli-reference
常见工作流 https://docs.claude.com/en/docs/claude-code/common-workflows
价格 https://claude.com/pricing
支持的国家和地区 https://www.anthropic.com/supported-countries

建议第一次严格按“计划 → 一组子命令 → 测试 → 提交”的节奏走完,熟悉每一步该检查什么。

之后再用同样的方法做你自己的小工具,需求文档写得越清楚,返工越少。