Citation Management skill for Claude Code

Clean bibliographies built from DOIs, not memory. Claude audits your .bib with a bundled script that flags duplicate keys, preprint-plus-published duplicates, missing fields and malformed DOIs, then rebuilds entries from doi.org and Crossref instead of guessing. It cross-checks cited against defined keys, sets up Zotero with Better BibTeX auto-export, and renders any CSL or BibLaTeX style with zero undefined citations.

  • namecitation-management
  • size176 lines · ≈2,000 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Manages references and bibliographies — BibTeX/BibLaTeX and CSL-JSON files, Zotero with Better BibTeX auto-export, DOI resolution through doi.org and Crossref, deduplication, citation keys, and formatting in APA, IEEE, Chicago, or any CSL style via pandoc citeproc or LaTeX. Use when cleaning a .bib file, fixing broken or duplicate citations, converting between citation formats, switching citation styles, setting up Zotero for a paper or thesis, or resolving DOIs into reference entries.

Try asking

  • My thesis has 40 undefined citation warnings and duplicate entries, clean up refs.bib
  • Convert this paper's references from IEEE to APA 7 using pandoc
  • Here is a list of paper titles, find their DOIs and build a BibTeX file

Use it when

  • A .bib file has duplicates, broken keys or missing DOIs
  • You need to switch citation styles for a new venue
  • You are setting up Zotero for a paper, thesis or team
  • You have titles or DOIs and need clean reference entries

Skip it when

How to check the result

bib_check.py exits 0, the document compiles with no undefined citations, and five resolved DOIs land on pages matching their entries.

The common mistake it prevents

Pasting BibTeX from Google Scholar, which often has wrong entry types, missing DOIs and a preprint where the published version exists.

~/.claude/skills/citation-management/SKILL.md

--- name: citation-management description: Manages references and bibliographies — BibTeX/BibLaTeX and CSL-JSON files, Zotero with Better BibTeX auto-export, DOI resolution through… license: MIT ---

Done well means: one canonical library per project, every entry has stable metadata from a primary source (DOI registry, publisher, arXiv), no duplicates or orphan keys, the document compiles with zero undefined citations, and the output style matches the target venue exactly.

Hard rules

  • Never fabricate an entry. Metadata comes from doi.org content negotiation, Crossref, PubMed, arXiv, the publisher page, or the user’s library. If a reference cannot be found, list it as unresolved instead of filling in plausible fields.
  • Never hand-type metadata for a work that has a DOI; resolve it.
  • Keep the source of truth in one place (Zotero library or one .bib). Generated files (Better BibTeX exports) are not edited by hand; edits go to the source.
  • Before deleting or merging entries, show the user the planned merges.

Pick the toolchain

SituationDefaultNotes
LaTeX paper, journal templateBibTeX + venue .bstMany venues require natbib/BibTeX, not BibLaTeX
LaTeX thesis, flexible stylesBibLaTeX + biberBetter Unicode, @online, urldate, many styles
Markdown, Quarto, Word via pandocCSL-JSON or .bib + --citeproc + .csl10,000+ styles in the CSL repository
Word / Google DocsZotero pluginKeep field codes live until final
Team, shared libraryZotero group library + Better BibTeX auto-exportCommit the exported .bib

Workflow

- [ ] 1. Inventory: find all .bib/.json files and citation commands in the project
- [ ] 2. Audit the bibliography (script below)
- [ ] 3. Resolve missing DOIs and fix metadata from primary sources
- [ ] 4. Deduplicate and normalize keys (with user approval for merges)
- [ ] 5. Cross-check keys: cited vs. defined
- [ ] 6. Apply the target style and compile
- [ ] 7. Spot-check rendered references against the style guide

1-2. Inventory and audit

find . -name '*.bib' -o -name '*.csl' -o -name 'references*.json' | grep -v node_modules
python3 scripts/bib_check.py refs.bib          # human-readable report, exit 1 on issues
python3 scripts/bib_check.py refs.bib --json   # machine-readable

scripts/bib_check.py reports duplicate keys, duplicate works (same DOI, or same normalized title and year, which catches preprint-plus-published pairs), missing required fields per entry type, malformed DOIs, DOIs stored as URLs, and articles with no DOI.

3. Resolve DOIs

# BibTeX straight from the DOI registry (works for Crossref, DataCite, mEDRA DOIs)
curl -sLH "Accept: application/x-bibtex" https://doi.org/10.1145/3442188.3445922

# CSL-JSON for pandoc workflows
curl -sLH "Accept: application/vnd.citationstyles.csl+json" https://doi.org/10.1145/3442188.3445922

# Find a DOI from a title (Crossref; add mailto for the polite pool)
curl -s "https://api.crossref.org/works?query.bibliographic=On+the+Dangers+of+Stochastic+Parrots&rows=3&[email protected]" \
  | python3 -c 'import sys,json; [print(i["DOI"], "|", i["title"][0]) for i in json.load(sys.stdin)["message"]["items"]]'

# arXiv metadata
curl -s "http://export.arxiv.org/api/query?id_list=1706.03762"

Matching rules for title search: accept a Crossref hit only when title, first author, and year all match. A close-but-different title is usually a different paper (a workshop version, a reply, or a chapter).

Check status while resolving:

  • Retractions and corrections: Crossref metadata carries update-to/relation info and Retraction Watch data; also check the publisher landing page.
  • Preprints later published: cite the published version unless the user read and relies on the preprint; add a note when versions differ.

4. Deduplicate and normalize

  • Merge order of preference: published version > accepted manuscript > preprint.
  • Keep the most complete record; carry over fields that only the other has (pages, URL).
  • Key convention: pick one and apply it everywhere. Better BibTeX default style is auth.lower + shorttitle(3,3) + year; a simpler common pattern is lastnameYEARfirstword (vaswani2017attention).
  • When renaming keys, update every \cite{}, @key, or [@key] in the manuscript in the same change, then recompile.
# keys cited in LaTeX or pandoc markdown
grep -ohE '\\cite[a-zA-Z]*\*?(\[[^]]*\])*\{[^}]+\}' -r --include='*.tex' . | sed -E 's/.*\{([^}]*)\}/\1/' | tr ',' '\n' | sed 's/ //g' | sort -u > cited.txt
grep -ohE '@[A-Za-z0-9_:.-]+' -r --include='*.md' --include='*.qmd' . | sed 's/^@//' | sort -u >> cited.txt
grep -oE '^@[a-zA-Z]+\{[^,]+' refs.bib | sed 's/.*{//' | sort -u > defined.txt
comm -23 <(sort -u cited.txt) defined.txt   # cited but undefined -> must fix
comm -13 <(sort -u cited.txt) defined.txt   # defined but never cited -> candidates to drop

The markdown grep also matches email addresses and decorators; review its output before acting on it.

6. Apply the style

LaTeX with BibLaTeX:

\usepackage[style=apa,backend=biber]{biblatex}   % or ieee, chicago-authordate, nature
\addbibresource{refs.bib}
...
\printbibliography
latexmk -pdf main.tex        # runs biber/bibtex as needed
grep -E "undefined|Citation .* undefined" main.log   # must be empty

Pandoc / Quarto:

curl -sLO https://raw.githubusercontent.com/citation-style-language/styles/master/apa.csl
pandoc paper.md --citeproc --bibliography refs.bib --csl apa.csl -o paper.docx

In Quarto, set bibliography: refs.bib and csl: apa.csl in the YAML front matter. Pandoc citation syntax: [@doe2020; @roe2021, p. 4], author-suppressed [-@doe2020], in-text @doe2020 says.

Zotero setup for a project

  1. Install Zotero (7 or later) and the Better BibTeX plugin.
  2. Create a collection per paper; add items via DOI/ISBN (“Add Item by Identifier”) or the browser connector, never by hand when an identifier exists.
  3. Right-click the collection, Export, format “Better BibTeX” (or “Better CSL JSON”), tick “Keep updated”. Point it at refs.bib in the repo.
  4. Pin citation keys in Better BibTeX if co-authors cite by key, so keys never drift.
  5. Use Zotero’s duplicate detection (“Duplicate Items”) before exporting.

BibTeX field hygiene

  • Protect capitalization that must survive styles: title = {Training {BERT} on {GPUs}}. Do not wrap the whole title in double braces; that blocks sentence-casing styles.
  • Authors: Last, First and Last, First; corporate authors in braces: author = {{World Health Organization}}.
  • doi holds the bare DOI (10.1145/...), not https://doi.org/....
  • Escape LaTeX specials in BibTeX (\&, \%); BibLaTeX with biber accepts UTF-8 directly.
  • Pages with an en dash: pages = {101--115}.
  • Online sources: @online (BibLaTeX) or @misc with url and urldate (BibLaTeX) or note = {Accessed 2025-10-14} (BibTeX).

Verification

  • python3 scripts/bib_check.py refs.bib exits 0, or every remaining issue is explained (e.g., a book chapter that genuinely has no DOI).
  • The document compiles with no undefined citation warnings; comm shows no cited-but- undefined keys.
  • Spot-check 5 rendered references against the target style guide (author format, italics, capitalization, DOI presentation).
  • Spot-check 5 DOIs by resolving them; the landing page must match the entry.

Gotchas

  • Google Scholar’s BibTeX export is often wrong (missing DOIs, mangled venues, wrong entry types). Use it to find the paper, then resolve the DOI.
  • arXiv papers have DataCite DOIs (10.48550/arXiv.XXXX.XXXXX); a later conference version has a different DOI. Both resolving is not a duplicate check failure; pick one.
  • BibTeX silently drops entries with syntax errors after the error. Run the audit after every bulk edit.
  • Journal abbreviations differ by venue; some styles require ISO 4 abbreviations.
  • Mixing natbib and biblatex in one preamble breaks compilation.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/bib_check.py 173 lines · 5.9 KB
#!/usr/bin/env python3
"""Audit a BibTeX file: duplicate keys, duplicate works (DOI or normalized
title+year), missing required fields, malformed DOIs, and DOI-less articles.

Usage: python3 bib_check.py refs.bib [--json]
Exit status: 0 clean, 1 problems found, 2 usage or parse error.
Standard library only.
"""
from __future__ import annotations

import json
import re
import sys
import unicodedata
from collections import defaultdict

REQUIRED = {
    "article": ["author", "title", "journal", "year"],
    "inproceedings": ["author", "title", "booktitle", "year"],
    "conference": ["author", "title", "booktitle", "year"],
    "book": ["title", "publisher", "year"],
    "incollection": ["author", "title", "booktitle", "publisher", "year"],
    "phdthesis": ["author", "title", "school", "year"],
    "mastersthesis": ["author", "title", "school", "year"],
    "techreport": ["author", "title", "institution", "year"],
    "misc": ["title"],
    "online": ["title", "url"],
}
DOI_RE = re.compile(r"^10\.\d{4,9}/\S+$")


def parse_bib(text: str) -> list[dict]:
    """Minimal brace-aware BibTeX parser; skips @comment/@preamble/@string."""
    entries, i, n = [], 0, len(text)
    while True:
        at = text.find("@", i)
        if at < 0:
            break
        m = re.match(r"@(\w+)\s*([{(])", text[at:])
        if not m:
            i = at + 1
            continue
        etype = m.group(1).lower()
        open_ch = m.group(2)
        close_ch = "}" if open_ch == "{" else ")"
        start = at + m.end()
        depth, j = 1, start
        while j < n and depth:
            c = text[j]
            if c == open_ch:
                depth += 1
            elif c == close_ch:
                depth -= 1
            j += 1
        body = text[start : j - 1]
        i = j
        if etype in ("comment", "preamble", "string"):
            continue
        key, _, rest = body.partition(",")
        fields = parse_fields(rest)
        line = text.count("\n", 0, at) + 1
        entries.append({"type": etype, "key": key.strip(), "fields": fields, "line": line})
    return entries


def parse_fields(s: str) -> dict:
    fields, i, n = {}, 0, len(s)
    while i < n:
        m = re.match(r"\s*,?\s*([\w:.-]+)\s*=\s*", s[i:])
        if not m:
            break
        name = m.group(1).lower()
        i += m.end()
        if i >= n:
            break
        if s[i] == "{":
            depth, j = 1, i + 1
            while j < n and depth:
                depth += {"{": 1, "}": -1}.get(s[j], 0)
                j += 1
            value = s[i + 1 : j - 1]
        elif s[i] == '"':
            j = i + 1
            while j < n and not (s[j] == '"' and s[j - 1] != "\\"):
                j += 1
            value = s[i + 1 : j]
            j += 1
        else:
            m2 = re.match(r"[^,]+", s[i:])
            value = m2.group(0) if m2 else ""
            j = i + len(value)
        fields[name] = " ".join(value.split())
        i = j
    return fields


def norm_title(t: str) -> str:
    t = unicodedata.normalize("NFKD", t)
    t = "".join(c for c in t if not unicodedata.combining(c))
    t = re.sub(r"\\[a-zA-Z]+|[{}]", "", t).lower()
    return re.sub(r"[^a-z0-9]+", " ", t).strip()


def norm_doi(d: str) -> str:
    d = d.strip().lower()
    d = re.sub(r"^(https?://)?(dx\.)?doi\.org/", "", d)
    return re.sub(r"^doi:\s*", "", d)


def audit(entries: list[dict]) -> list[dict]:
    issues = []
    add = lambda kind, e, msg: issues.append({"kind": kind, "key": e["key"], "line": e["line"], "msg": msg})
    keys, dois, titles = defaultdict(list), defaultdict(list), defaultdict(list)
    for e in entries:
        f = e["fields"]
        keys[e["key"]].append(e)
        for req in REQUIRED.get(e["type"], ["title"]):
            if req == "author" and "editor" in f:
                continue
            if not f.get(req):
                add("missing-field", e, f"@{e['type']} lacks '{req}'")
        if "doi" in f:
            d = norm_doi(f["doi"])
            if not DOI_RE.match(d):
                add("bad-doi", e, f"malformed DOI '{f['doi']}'")
            else:
                dois[d].append(e)
                if f["doi"].strip().lower() != d:
                    add("doi-format", e, "store bare DOI (10.xxxx/...), not a URL or 'doi:' prefix")
        elif e["type"] == "article":
            add("no-doi", e, "article without DOI; resolve via Crossref or confirm none exists")
        if f.get("title"):
            titles[(norm_title(f["title"]), f.get("year", ""))].append(e)
    for k, group in keys.items():
        if len(group) > 1:
            add("duplicate-key", group[1], f"key also defined at line {group[0]['line']}")
    seen_pairs = set()
    for d, group in dois.items():
        if len(group) > 1:
            add("duplicate-work", group[1], f"same DOI {d} as '{group[0]['key']}'")
            seen_pairs.add(frozenset(x["key"] for x in group))
    for (t, y), group in titles.items():
        if len(group) > 1 and frozenset(x["key"] for x in group) not in seen_pairs:
            add("duplicate-work", group[1], f"same title+year as '{group[0]['key']}' (check preprint vs published)")
    return issues


def main(argv: list[str]) -> int:
    args = [a for a in argv if not a.startswith("--")]
    if len(args) != 1:
        print(__doc__.strip(), file=sys.stderr)
        return 2
    try:
        text = open(args[0], encoding="utf-8").read()
    except OSError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2
    entries = parse_bib(text)
    if not entries:
        print("error: no BibTeX entries found", file=sys.stderr)
        return 2
    issues = audit(entries)
    if "--json" in argv:
        print(json.dumps({"entries": len(entries), "issues": issues}, indent=2))
    else:
        print(f"{len(entries)} entries, {len(issues)} issue(s)")
        for it in sorted(issues, key=lambda x: x["line"]):
            print(f"  line {it['line']:>5}  {it['kind']:<15} {it['key']}: {it['msg']}")
    return 1 if issues else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc