Create a library at a location you choose and record it as the active library.
/ref:init <path> [--force]
Arguments
- <path>
- Required. Where the library is created. If you leave it out, Claude asks for it rather than guessing; the plugin never hardcodes a location.
- --force
- Reconfigure even if a library is already recorded in the config file.
What happens
- Refuses if
~/.config/ref-manager/config.json already exists and --force was not given, naming the configured library.
- Creates the core library directories (
papers, index, exports and a few subfolders) and a log.md. Everything else (projects, people, labs, grants, reports, studies, graph, okf, queries) is created lazily the first time a command writes into it. Existing directories are left alone.
- Writes
{"library_root": …} to the config file atomically.
After this, no other command needs a --repo argument: they all resolve the active library from the config file, and fail loudly, pointing back here, if it is missing.
Reads & writes
Writes <path>/ (directory skeleton, log.md) and ~/.config/ref-manager/config.json.
Example
output from a scratch library (paths shortened)
› /ref:init ~/scratch/lib
library initialized at /private/tmp/…/scratch/lib
config written to /private/tmp/…/scratch/.config/ref-manager/config.json
› /ref:init ~/scratch/lib
error: a library is already configured at /private/tmp/…/scratch/lib (/private/tmp/…/scratch/.config/ref-manager/config.json). Pass --force to reconfigure.
Under the hood
init_repo.py <path> [--force]
See also
/ref:status · Get started · Concepts
/ref:add
phase 1PubMed MCP
Resolve one or more PMIDs through PubMed and ingest them as new, deduplicated library records.
/ref:add <pmid…>
Arguments
- <pmid…>
- One or more PubMed IDs, required. PMID is the identity key: every paper resolves to a PMID before anything else happens.
What happens
- Claude calls the PubMed MCP tool
get_article_metadata once for all PMIDs and normalizes each article into a JSON envelope (title, abstract, ordered authors, journal, year, DOI, PMCID, indexed grants). A PMID the tool returned nothing for is still passed on, so it is reported as failed rather than silently dropped.
- The script takes a per-PMID lock. A PMID that already has
meta.json is reported already_present and left untouched.
- A new record gets a stable citekey (first author, year, first title word), the exact PubMed response stored under its hash, and author order preserved as published.
- A paper with no abstract is still
added, as a metadata-only record with extraction_tier: "unavailable". Nothing is fabricated.
- If the DOI matches a different existing PMID, or the same title is attached to a different DOI, a warning is printed and both records are kept. DOI/title mismatches never silently merge records.
Reads & writes
Writes papers/<pmid>/meta.json, authorship.json (raw_order_preserved: true), funding.json (indexed GrantList entries, or state: "not_checked"), and raw/<sha256>/response.json. Reads other papers' meta.json for the DOI/title conflict check and prints warnings when the identity signals disagree.
Example
output from a scratch library
› /ref:add 31452104 30679568 99999999
# the PMIDs and their metadata are made-up fixtures, not real papers
31452104: added (citekey=rivera2019cortical)
30679568: added (citekey=chen2019editorial) — no abstract available — metadata-only record, not fabricated (§3a)
99999999: failed — no metadata returned for PMID 99999999
› /ref:add 31452104
31452104: already_present (citekey=rivera2019cortical)
Under the hood
add.py add --repo <library_root> --metadata-file <json>. The script never calls the network; the command resolves metadata through PubMed MCP first and hands it the envelope documented in commands/ref-add.md.
See also
/ref:fetch · /ref:extract · /ref:search-pubmed · /ref:project · /ref:cite
/ref:add-pdf
phase 3PubMed MCP
Identify local PDFs from DOI/PMID clues, add their PubMed records, then attach the PDFs.
/ref:add-pdf <path…> [--force]
Arguments
- <path…>
- One or more local PDF paths.
- --force
- Pass through to attachment if the PDF identity check refuses and you explicitly accept the match.
What happens
- Runs
pdf_identify.py locally with pdftotext to extract DOI, PMID, PMCID, and a title guess from the first pages.
- Uses PMID directly when found. Otherwise, DOI or PMCID clues are resolved through PubMed MCP to a candidate PMID; ambiguous matches are shown for confirmation.
- Calls the normal
/ref:add metadata path for resolved PMIDs, preserving PMID as the identity key.
- Calls the normal
/ref:attach PDF pipeline for each resolved PMID/path pair.
DOI and title are only resolution clues. They never merge records, and a weak or ambiguous match should be confirmed before attaching.
Reads & writes
Reads local PDFs. Writes the same files as /ref:add and /ref:attach: metadata records, raw PubMed responses, raw PDF bytes, attachment provenance, and converted versions when PDF conversion succeeds.
Example
identify, add, then attach
› /ref:add-pdf ~/Downloads/luckhardt2025.pdf
~/Downloads/luckhardt2025.pdf: identified doi=10.1002/aur.70084 → pmid=40665956
40665956: added (citekey=luckhardt2025transcranial)
40665956: attached
Under the hood
pdf_identify.py <path…>, PubMed MCP lookup, add.py add --repo … --metadata-file …, then attach.py --repo … <pmid> <path>.
See also
/ref:add · /ref:attach · /ref:fetch-pdf
/ref:add-fetch
phase 3PubMed MCPUnpaywall
Add missing PMID records, then immediately try to acquire full text for every requested PMID that now exists in the library.
/ref:add-fetch <pmid…>
Arguments
- <pmid…>
- One or more PubMed IDs, required. Existing records are kept and still continue to the fetch stage.
What happens
- Runs the same metadata resolution and ingest flow as
/ref:add, reporting added, already_present, or failed per PMID.
- Builds a fetch set from requested PMIDs that have
papers/<pmid>/meta.json after the add stage. PMIDs that failed to add are skipped instead of producing a second predictable failure.
- Runs the same acquisition ladder as
/ref:fetch: PMC JATS, PubMed MCP plain text, Unpaywall, publisher HTML, or abstract-only.
This is orchestration only. add.py and fetch.py stay separate so creating metadata records remains distinct from acquiring full text.
Reads & writes
Writes everything /ref:add may write, then everything /ref:fetch may write for PMIDs that have records.
Example
illustrative output
› /ref:add-fetch 40665956 99999999
add:
40665956: added (citekey=luckhardt2025transcranial)
99999999: failed — no metadata returned for PMID 99999999
fetch:
40665956: acquired (source=pmc_jats, figures=3, images=3/3)
Under the hood
First runs add.py add --repo <library_root> --metadata-file <json>, then runs fetch.py --repo <library_root> --input-file <json> for PMIDs with meta.json.
See also
/ref:add · /ref:fetch · /ref:attach
/ref:add-url
phase 3PubMed MCPweb fetch
Identify article URLs from DOI/PMID/PMCID clues, add their PubMed records, then fetch available full text.
/ref:add-url <url…> [--no-fetch]
Arguments
- <url…>
- One or more article URLs — PubMed, PMC, DOI, or publisher URLs.
- --no-fetch
- Add metadata only; skip the fetch stage.
What happens
- Runs
url_identify.py locally, a deterministic, network-free parser of stable URL patterns, to pull DOI, PMID, or PMCID clues straight from the URL string.
- Uses PMID directly when found. Otherwise resolves a DOI or PMCID clue to a candidate PMID through PubMed MCP.
- If the URL itself carries no clue, fetches the page HTML and extracts citation metadata from common tags (
citation_doi, DC.Identifier, schema.org JSON-LD, citation_pmid, citation_pmcid), then repeats DOI/PMID/PMCID resolution.
- Confirms candidates against PubMed
get_article_metadata before adding — DOI and title clues must be compatible with what PubMed has. Zero, ambiguous, or weak matches are shown to the user to pick, confirm, or skip.
- Calls the normal
/ref:add metadata path for resolved PMIDs, preserving PMID as the identity key.
- Unless
--no-fetch was passed, runs the same acquisition ladder as /ref:fetch for added/already-present PMIDs.
URL, DOI, and title are only resolution clues. They never merge records, and fetching a publisher page is not permission to bypass paywalls — full text still follows the /ref:fetch legal/available-source ladder. One URL's failed identification does not block the rest.
Reads & writes
Writes the same files as /ref:add and, unless skipped, /ref:fetch.
Example
identify, add, then fetch
› /ref:add-url https://doi.org/10.1002/aur.70084
identify:
https://doi.org/10.1002/aur.70084: doi -> 40665956
add:
40665956: added (citekey=luckhardt2025transcranial)
fetch:
40665956: acquired (source=pmc_jats, figures=3, images=3/3)
Under the hood
url_identify.py <url…>, PubMed MCP lookup/confirmation, add.py add --repo … --metadata-file …, then the same fetch path as /ref:fetch.
See also
/ref:add · /ref:add-pdf · /ref:fetch · /ref:import
/ref:import
phase 6PubMed MCPweb fetch
Source-aware front door: feed it a mix of PMIDs, article URLs, local PDFs or folders, DOIs, or bibliography files, and it routes each one into the right add/fetch/attach flow.
/ref:import <item…> [--no-fetch]
Arguments
- <item…>
- One or more inputs, in any mix: bare PMID tokens (
40691132), article URLs, local PDF paths, folders containing PDFs, BibTeX (.bib) files, CSL-JSON (.csl.json) files, or bare DOI strings (10.1038/s41586-026-00001-2).
- --no-fetch
- Add metadata only; skip full-text acquisition for every input.
What happens
- Splits the inputs by type before doing anything else: URL-like inputs go through the same identification step as
/ref:add-url; existing .pdf paths go through the same identification step as /ref:add-pdf; directories are expanded to the PDFs they contain and then treated the same way; .bib/.csl.json files are read as citation source material and resolved to PMIDs; bare DOI strings are resolved through PubMed MCP, with confirmation whenever a DOI maps to zero or multiple PMIDs; bare PMID tokens are used directly.
- URL and PDF inputs reuse the existing local, network-free identification helpers (
url_identify.py, pdf_identify.py) to pull DOI/PMID/PMCID clues before ever calling PubMed MCP.
- Any non-PMID clue (DOI, PMCID, a weak title match, an unmatched bibliography entry) is resolved to a PMID through PubMed MCP, asking you to confirm whenever the match is ambiguous.
- Calls PubMed MCP
get_article_metadata once for every resolved PMID and normalizes the results into the standard /ref:add envelope, then runs the normal add path.
- Unless
--no-fetch was passed, runs the same fetch priority ladder as /ref:fetch — PMC JATS, PubMed plain text, Unpaywall, publisher HTML, otherwise abstract-only — for every PMID that now has a record.
- Prints every stage's output under
identify:, add:, and fetch: headers, so you can see where each item came from and whether it was added, already present, attached, or fetched.
PMID stays the identity key throughout: DOI, URL, PDF, and bibliography clues only resolve a candidate PMID, they never merge records. This command is a dispatcher, not a new storage path — it reuses the existing add, fetch, attach, URL-identify, and PDF-identify flows rather than inventing a parallel one. One input's failure never blocks the rest.
Reads & writes
Reads local PDFs/folders/bibliography files you point it at. Writes the same files as /ref:add and, unless --no-fetch is passed, the same files as /ref:fetch — nothing beyond what those commands already write.
Example
illustrative output, mixed intake
› /ref:import 40665956 https://doi.org/10.1002/aur.70084 ~/Downloads/luckhardt2025.pdf
identify:
40665956: pmid -> 40665956
https://doi.org/10.1002/aur.70084: doi -> 40665956
~/Downloads/luckhardt2025.pdf: pdf(doi=10.1002/aur.70084) -> 40665956
add:
40665956: already_present (citekey=luckhardt2025transcranial)
fetch:
40665956: duplicate_noop
Under the hood
url_identify.py and/or pdf_identify.py for local clue extraction, PubMed MCP for DOI/PMCID/title resolution and get_article_metadata, then add.py add --repo <library_root> --metadata-file <json> and, unless skipped, the same fetch.py path as /ref:fetch. No new script — pure orchestration over the existing intake pipeline.
See also
/ref:add · /ref:add-pdf · /ref:add-url · /ref:fetch
/ref:fetch
phase 3PubMed MCPUnpaywall
Try to acquire full text for papers already in the library, in a fixed priority order, and commit it as a new version.
/ref:fetch <pmid…>
Arguments
- <pmid…>
- One or more PubMed IDs, required. Each must already have
papers/<pmid>/meta.json. Fetch adds to a record; it never creates one.
If no PMID is given at all, Claude runs the shared lib_selector.py recent picker (also available with --kind unresolved, for papers still needing source follow-up) and presents results via a multi-select question labelled <title> (<citekey>, <year>), instead of failing or asking you to recall PMIDs from memory.
What happens
For each PMID, the first source that succeeds wins:
- PMC open access. Claude calls
get_full_text_article. That tool returns pre-extracted plain text, not JATS, so the text is passed as plain_text. Genuine JATS XML is only supplied if obtained some other way; the converter rejects malformed XML rather than silently flattening its structure.
- Unpaywall. With a DOI and an
unpaywall_email in the config, the script calls the Unpaywall API itself. If it finds an OA PDF, it records the location in meta.json and reports oa_location_found. It does not download the PDF; you do that and hand it to /ref:attach.
- Publisher HTML. Claude fetches the article page and passes it as
publisher_html; it is converted with trafilatura.
- Nothing. The paper is marked
full_text: false and reported abstract_only. This is expected, not a failure.
The raw input is preserved under its hash, converted into a staging version, and committed atomically. If the current fetched source already has the same source kind and raw hash, the command reports duplicate_noop and does not create a new version. When a changed fetch is committed, older complete fetch-created version directories are pruned so only the final current full-text acquisition version remains; content-addressed raw/<sha256>/... evidence is preserved. JATS sources also get funding acknowledgements scanned and appended to funding.json. When JATS figure locators are present, figure bytes are fetched from PMC OA asset URLs if a PMCID is known, then from known DOI-keyed publisher patterns. diagnostic: lines flag incomplete conversion (missing tables, math, figure assets, lost structure) without failing the fetch.
Unpaywall needs a config key. /ref:init writes only library_root. unpaywall_email is an optional key in ~/.config/ref-manager/config.json; without it, the Unpaywall step is skipped.
Reads & writes
Writes raw/<sha256>/source.jats|html|plain_text, versions/<v>/source.md, figures.json (JATS figure locators), manifest.json, current.json; updates meta.json (full_text, oa_location) and, for JATS, funding.json. Reads unpaywall_email from the config.
Example
output from a scratch library
› /ref:fetch 31452104 30679568 11111111
31452104: acquired (source=plain_text)
diagnostic: source has no markup (plain extracted text); section/heading structure and figures are not recoverable
30679568: abstract_only -- no full text available from any source (§6) -- abstract-only stays first-class
11111111: failed -- pmid 11111111 has no meta.json -- run /ref:add first
Under the hood
fetch.py --repo <library_root> --input-file <json>, with converters from convert.py (pandoc for JATS, trafilatura for HTML, a pass-through for plain text). Apart from the single Unpaywall call, the script never fetches URLs itself.
See also
/ref:attach · /ref:extract · /ref:open · Workflows
/ref:fetch-pdf
phase 3PMC OA
Download free PMC Open Access PDFs for papers already in the library and attach them through the normal PDF pipeline.
/ref:fetch-pdf <pmid…>
Arguments
- <pmid…>
- One or more PubMed IDs, required. Each must already have
papers/<pmid>/meta.json and a PMCID.
- --force
- Attach even if the PDF identity check fails; otherwise the result is
refused.
What happens
- Reads each paper's PMCID from
meta.json.
- Uses the documented PMC OA Web Service to discover a downloadable PDF link. A PMCID is necessary but not sufficient; some PMC records have public HTML/JATS full text but no OA PDF link.
- Downloads PDF bytes and passes them through the same identity check, raw hash storage, conversion, and version commit path as
/ref:attach.
This command does not scrape the PMC PDF viewer and does not bypass access controls. When a PDF is unavailable but PMC E-utilities JATS is available, it reports that distinction and points back to /ref:fetch. For structured text and figure locators, /ref:fetch remains the better default because it prefers PMC JATS XML.
Reads & writes
Reads meta.json. Writes raw/<sha256>/source.pdf, raw/<sha256>/attachment.json, versions/<v>/manifest.json and current.json; updates meta.json when conversion succeeds.
Example
illustrative output
› /ref:fetch-pdf 40665956
40665956: no_pdf -- PMC OA lookup failed (...): HTTP Error 404: Not Found -- PMC JATS full text is available; use /ref:fetch for structured full text and figures
Under the hood
fetch_pmc_pdf.py --repo <library_root> [--force] <pmid…>, then attach.py's shared PDF byte attachment path.
See also
/ref:fetch · /ref:attach · /ref:export-papers
Attach PDFs you already have to existing records, after checking each PDF really is that paper.
/ref:attach <pmid> <path> [<pmid> <path> …] [--force]
Arguments
- <pmid> <path>
- One or more PMID/file pairs, required. Each PMID must already have
meta.json.
- --force
- Attach even when the identity check fails. Without it, a failed check is refused.
If no <pmid> <path> pairs are given at all, Claude runs the shared lib_selector.py recent picker and presents the 15 most recent papers via a multi-select question (<title> (<citekey>, <year>)) instead of asking you to recall PMIDs from memory; you still supply a PDF path for each paper you tick.
What happens
- Each pair is handled under its own per-PMID lock.
- If a file with identical bytes (same SHA-256) is already stored, the result is
duplicate_noop.
- Identity check:
pdftotext reads the first two pages. The check passes if the paper's DOI, or its first eight title words (for titles of six or more words), appear in that text. Otherwise the pair is refused, with the reason, so a wrong PDF is never attached silently. If pdftotext is unavailable, identity cannot be verified and the pair is refused as well.
- The PDF is stored under
raw/<sha256>/ with an attachment.json recording where it came from and how identity was checked (including whether it was forced).
- The PDF is converted with anydoc into a new version and committed. If anydoc is not available, a diagnostic says so and
full_text stays unset.
Reads & writes
Writes raw/<sha256>/source.pdf, raw/<sha256>/attachment.json, versions/<v>/manifest.json (plus source.md when conversion succeeds), current.json; updates meta.json (full_text). Reads your PDF, never modifying it.
Example
illustrative output
› /ref:attach 31452104 ~/Downloads/rivera2019.pdf 30679568 ~/Downloads/wrong.pdf
31452104: attached
30679568: refused -- neither DOI nor a run of the title text was found in the PDF's first 2 page(s) -- refusing silent attachment (§6)
# if you're sure the second file is right:
› /ref:attach 30679568 ~/Downloads/wrong.pdf --force
output from a scratch library
› /ref:attach 31452104 ~/scratch/nope.pdf
31452104: failed -- no such file: /private/tmp/…/scratch/nope.pdf
Under the hood
attach.py --repo <library_root> [--force] <pmid1> <path1> [<pmid2> <path2> …]; conversion via convert.py (anydoc). Uses pdftotext (poppler) for the identity check.
See also
/ref:fetch · /ref:open · /ref:export-papers · Papers handoff tutorial
Render a local reading copy from the current converted full text and any downloaded figure images.
/ref:read <pmid…> [--engine simple|quarto] [--format html|pdf|both] [--table-images] [--open]
Arguments
- <pmid…>
- One or more PubMed IDs. Each paper must already exist and have a current version with
source.md.
- --engine simple|quarto
- Renderer.
simple writes HTML directly; quarto writes article.qmd and renders through Quarto.
- --format html|pdf|both
- Output format for
--engine quarto. The simple renderer supports HTML only.
- --table-images
- With Quarto HTML output, snapshot each rendered table to
reader/tables/table-<n>.png and insert the image below the live table.
- --open
- Open the primary rendered file after rendering.
What happens
- Reads
current.json, versions/<v>/source.md, and, when present, versions/<v>/figures.json.
- Writes
papers/<pmid>/reader/article.html with article metadata, converted text, and local figure links. In Quarto mode it also writes article.qmd and can produce article.pdf.
- Rewrites inline figure references from converted JATS/Pandoc output to point at downloaded assets in
versions/<v>/figures/; missing images do not fail the render.
- When
--table-images is set, keeps live/searchable HTML tables and adds PNG snapshots under them for stable visual reading.
This is a reading reconstruction, not the publisher PDF layout. It is best for PMC JATS or attached-PDF conversions when you want a quick local page to read without importing into Papers.app.
Reads & writes
Reads meta.json, current.json, versions/<v>/source.md, and optional figures.json. Writes reader/article.html, reader/manifest.json, and in Quarto mode reader/article.qmd plus requested rendered outputs. With --table-images, also writes reader/tables/*.png.
Example
render and open a paper with all figure assets
› /ref:read 40665956 --engine quarto --format both --table-images --open
40665956: rendered -- /Users/.../papers/40665956/reader/article.html (figures=3, images=3/3) inline_figures_rewritten=3 table_images=3
output: /Users/.../papers/40665956/reader/article.pdf
Under the hood
read_article.py --repo <library_root> [--engine simple|quarto] [--format html|pdf|both] [--table-images] [--open] <pmid…>. No network, no subagent, no publisher scraping.
See also
/ref:fetch · /ref:attach · /ref:open
/ref:describe-figure
phase 4
Ask for a model description of one figure, on demand, and cache it next to the figure without mixing it into the paper's own caption.
/ref:describe-figure <pmid> <figure-id> [--model <name>] [--prompt <text>]
Arguments
- <pmid>
- Required.
- <figure-id>
- Required. An
id in figures.json of the paper's current version.
- --model <name>
- Defaults to the calling model's own name.
- --prompt <text>
- Defaults to a plain "describe this figure" prompt. The cache key is the exact (figure hash, model, prompt) triple, so a different prompt is a new request.
If no <pmid> is given at all, Claude runs the shared lib_selector.py recent picker and presents results via a multi-select question labelled <title> (<citekey>, <year>), instead of asking you to recall a PMID from memory. Once a paper is picked, Claude runs vision.py list --repo <library_root> --pmid <pmid> so you can choose a figure from that version's inventory (id + caption) rather than needing to already know the figure ID.
What happens
request looks the figure up and branches on status:
cache_hit: the stored description is shown, labelled as a prior model interpretation.
asset_unavailable: the image bytes were never fetched (common, since JATS/HTML conversion records figure locators, not bytes). There is nothing to look at, so no description is written.
figure_not_found: the figure ID is wrong.
needs_description: Claude looks at the actual image and writes a description. It says so if the image isn't viewable, rather than guessing from the caption.
store saves the description in the figure's vision list with kind: "model_interpretation", the figure hash, model, prompt, and timestamp. It replaces any earlier entry for the same model and prompt.
This never runs automatically over every figure. A description is always presented as a model interpretation, separate from the caption and the paper's reported findings.
Reads & writes
Reads current.json and versions/<v>/figures.json; store writes the vision[] entry back into that figures.json.
Example
illustrative output
› /ref:describe-figure 31452104 F2
{
"status": "asset_unavailable",
"figure_id": "F2",
"reason": "figure image bytes were not fetched during conversion; nothing to describe"
}
output from a scratch library
› /ref:describe-figure 31452104 fig1
{
"status": "figure_not_found",
"figure_id": "fig1"
}
Under the hood
vision.py request --repo <library_root> --pmid <pmid> --figure-id <id> --model <model> --prompt <prompt>, then on a cache miss vision.py store … --description "<text>". The script never calls a vision model; the calling agent does the looking. Both accept --version (default current).
See also
/ref:fetch · /ref:verify · Concepts