Skip to content

CLI reference

The researcher-profiles package installs a single console script, rp. Run rp <subcommand> --help for per-command usage.

rp <subcommand> [options]
SubcommandPurposeExtra required
indexBuild or update a profile’s embedding index[vectors,st]
export-embeddingsWrite the servable flat embedding form (embeddings/) from the sqlite index[vectors,st]
exportRender a profile as one text blob (plus metadata) for a knowledge basecore
searchQuery a profile’s embedding index[vectors,st]
rank-worksRank new candidate works against a profile’s embedding vector[vectors,st]
schemaExport JSON Schema files for the profile formatcore
graphBuild or inspect the derived profile graph (coauthor / COI / advising edges)core
dbPush, pull and inspect profiles in a SQL profile store[sql]
pushUpload a built profile directory to a remote API server[client]
renderRender index.html into a profile folder and refresh its manifest (in place)core
publishWrite the static site one audience may see into a folder any sync tool can upload as iscore
siteWrite the collection files (index.json, by-rid.json, SKILL.md, sitemap.xml, …) for a set of profilescore
installPull profiles from a registry into the local cache[client]
seekPrint the local path of an installed profilecore
listList profiles in the local cachecore
listrList profiles available on a registry[client]
loginLog in to a profile server and store a push key[client]
logoutForget the stored logincore
whoamiShow who the stored login is, if the server sayscore
skillPrint or install the consumer skill for reading published profilescore
wherePrint the profile directory for a rid or a slugcore
mint-local-idMint a local: rid for a researcher who has no ORCIDcore
validateValidate a profile directory against the JSON Schema speccore
manifestShow or regenerate a profile’s manifestcore
signSign a profile’s profile.jsonld with a key_signature proof[signing]
sign-verifyVerify the key_signature proof(s) on a profile.jsonld against a JWK Set[signing]

The table above lists every verb the parser registers. Every row has a dedicated section below with its flags, exit codes, and a worked example.

The HTTP server has a separate entry point, python -m researcher_profiles.api; see the API reference.

Every verb that takes a profile argument accepts three spellings of it:

SpellingExample
A path to a profile directory./profiles/jane-doe
A rid0000-0002-1825-0097
A directory slug in the local cachejane-doe

A path that exists on disk always means itself; only a name that is nothing on disk is looked up in the cache, so a local directory is never shadowed by a same-named entry in the cache. --root DIR names the cache to look in, and otherwise the usual order applies: $RESEARCHER_PROFILES_ROOT, else ~/researcher-profiles. A name that resolves to nothing exits 2 and prints the root it searched.

This is why rp install jane-doe and rp index jane-doe fit together: install prints the slug, and every later verb takes it.


Build (or incrementally update) the embedding index for a profile. The index is written to <profile>/.cache/embeddings.sqlite.

rp index <profile> [--root DIR] [--force] [--backend SPEC]
Argument / flagDescription
profileA profile directory, a rid, or a slug (see Naming a profile).
--root DIRProfiles root to resolve a rid or slug against. Default: $RESEARCHER_PROFILES_ROOT, else ~/researcher-profiles.
--forceDrop and rebuild the index from scratch.
--backend SPECBackend spec, e.g. st:all-MiniLM-L6-v2. Defaults to the profile’s recorded backend or the package default.

Prints a one-line report:

backend=st:all-MiniLM-L6-v2 added=4 updated=0 skipped=0 removed=0 duration=5.17s

Requires the [vectors,st] extras.


Write the servable flat embedding form (<profile>/embeddings/) from the sqlite index at <profile>/.cache/embeddings.sqlite. The flat form is what a static or dynamic server ships; the sqlite form is a local build artifact.

rp export-embeddings <profile> [--root DIR]
ArgumentDescription
profileA profile directory, a rid, or a slug (see Naming a profile).
--root DIRProfiles root to resolve a rid or slug against. Default: $RESEARCHER_PROFILES_ROOT, else ~/researcher-profiles.
Terminal window
$ rp export-embeddings $(rp where jane-doe)
wrote embeddings/jane-doe.bin backend=st:all-MiniLM-L6-v2 count=42 dim=384 dropped=3

When there is no sqlite index, or no public row survives the privacy tier filter, it prints no flat form written and exits 0. Any stale flat form on disk is removed and hasEmbeddingIndex is left false. A name that resolves to no directory at all exits 2; a directory that exists but holds no index takes the same no-op path as an empty one.

Requires the [vectors,st] extras.


Query a profile’s embedding index and print ranked hits.

rp search <profile> <query> [--root DIR] [-k N] [--type SOURCE_TYPE] [--json]
Argument / flagDefaultDescription
profilenoneA profile directory, a rid, or a slug (see Naming a profile).
--root DIRnoneProfiles root to resolve a rid or slug against. Default: $RESEARCHER_PROFILES_ROOT, else ~/researcher-profiles.
querynoneFree-text query.
-k N5Number of hits to return.
--type SOURCE_TYPEnoneFilter by source type: paper_summary, paper_abstract, expertise, soul, grant, cv, or web.
--jsonoffEmit the hits as a JSON array instead of the text listing.

Each hit prints its score, source, chunk index, section, and a text preview:

[0.595] expertise:expertise#1 Region set analysis
## Region set analysis Jane developed methods for enrichment analysis ...

Requires the [vectors,st] extras.


Rank new candidate works against a profile’s embedding vector: the “what’s new that fits this researcher” query. Needs a built embedding index (rp index).

rp rank-works <profile> [--root ROOT] [--since YYYY-MM-DD] [--openalex]
[--works FILE] [-k N]
[--kind {centroid,summary,expertise}] [--threshold X]
[--exclude-types T1,T2] [--all-types]
[--json]
Argument / flagDefaultDescription
profilenoneA profile directory, a rid, or a slug.
--root ROOTnoneProfiles root, used to resolve a rid/slug.
--since YYYY-MM-DD30 days backEarliest publication date, for --openalex.
--openalexoffFetch candidates from the OpenAlex API. Needs the [client] extra.
--works FILEnoneJSON array of candidate works (raw OpenAlex works or PaperRecord dicts), instead of --openalex.
-k N10Number of ranked works to return.
--kind {centroid,summary,expertise}centroidWhich profile vector to rank against.
--threshold XnoneDrop works scoring below X.
--exclude-types T1,T2a default deposit-type setOpenAlex work types to drop from --openalex candidates (software, book, etc.).
--all-typesoffKeep every work type; disables the deposit-type filter.
--jsonoffEmit the ranked works as a JSON array.

Exactly one of --openalex or --works is required. --openalex reads the OpenAlex API key from the OPENALEX_API_KEY environment variable (a free key from https://openalex.org/settings/api); there is no command-line flag for it, so it stays out of shell history.

Terminal window
rp rank-works voss-elena --openalex --since 2026-07-01
rp rank-works voss-elena --works candidates.json -k 10 --json

Exit codes. 0 ok (including “no candidate works survived ranking”) · 1 the profile could not be loaded, the profile has no subfields/interests or OpenAlex work ids to seed a query with, the OpenAlex fetch failed, or the [vectors,st] extras are missing · 2 neither --openalex nor --works was given, --works FILE could not be read as JSON, or the index has not been built (run rp index first).

Requires the [vectors,st] extras; --openalex additionally needs [client].


Render a profile as a single deterministic text blob for a knowledge base, or the whole export bundle (identity, links, the corpus DOI list, the text, and a content hash) as canonical JSON.

rp export <profile> [--json] [-o FILE] [--max-papers N] [--char-budget N]
[--no-soul] [--no-expertise-doc]
[--explore-base URL] [--profile-url URL] [--allow-nonpublic]
Argument / flagDescription
profileA profile directory, a rid, or a slug (see Naming a profile).
--root DIRProfiles root to resolve a rid or slug against. Default: $RESEARCHER_PROFILES_ROOT, else ~/researcher-profiles.
--jsonEmit the export bundle as canonical JSON instead of the bare markdown.
-o, --out FILEWrite to FILE instead of stdout.
--max-papers NCap on papers whose text enters the blob (default 40).
--char-budget NSoft cap on blob characters (default 120000). Whole paper blocks are dropped; a body is never truncated.
--no-soulOmit personality/SOUL.md.
--no-expertise-docOmit personality/expertise.md.
--explore-base URLBase URL of a profile browser app, used to build the backlink. No default: without it the bundle carries no explore_url.
--profile-url URLOverride the published profile URL (default: the document’s url).
--allow-nonpublicExport a profile whose visibility is above public.
Terminal window
rp export $(rp where jane-doe) # the blob
rp export $(rp where jane-doe) --json # the bundle
rp export $(rp where jane-doe) --json -o jane-doe.export.json

The blob is deterministic: two runs against an unchanged directory produce byte-identical output, which is what lets a knowledge base upsert only when content_hash changes.

Exit codes. 0 ok · 1 the profile could not be loaded · 4 the profile’s visibility is above public and --allow-nonpublic was not given; the message names the flag.

See Export for knowledge bases.


Export JSON Schema files for the profile format.

rp schema export <out_dir>
rp schema export-wire <out_file>

export writes one file per model. export-wire writes a single combined HTTP wire-contract schema, consumed by rp-ui-lib/scripts/gen-types.mjs to generate the browser’s TypeScript types.

ArgumentDescription
out_dir (export)Directory to write <model>.schema.json files into. Created if absent.
out_file (export-wire)Output file for the combined wire schema, e.g. rp-ui-lib/schemas/wire.schema.json.

export writes nine files and prints each path:

wrote schemas/profile_jsonld.schema.json
wrote schemas/papers_jsonld.schema.json
wrote schemas/grants_jsonld.schema.json
wrote schemas/summary_file.schema.json
wrote schemas/embedding_index.schema.json
wrote schemas/profile_list.schema.json
wrote schemas/collection.schema.json
wrote schemas/topic_index.schema.json
wrote schemas/profile_export_bundle.schema.json
Terminal window
rp schema export-wire rp-ui-lib/schemas/wire.schema.json

See the schema reference.


Validate a profile directory against the JSON Schema spec: schema conformance plus manifest agreement. Core; no extra required.

rp validate <profile> [--root DIR] [--json]
Argument / flagDescription
profileA profile directory, a rid, or a slug (see Naming a profile).
--root DIRProfiles root to resolve a rid or slug against. Default: $RESEARCHER_PROFILES_ROOT, else ~/researcher-profiles.
--jsonOutput the report as JSON instead of text.
Terminal window
rp validate $(rp where voss-elena)
rp validate ./profiles/voss-elena --json

A pass with undeclared JSON-LD terms prints a warning banner on stderr; that is not a conformance failure.

Exit codes. 0 conformant · 4 a conformance violation. Re-read the violations printed, or fetch them keyed by JSON pointer with --json.


Build (or inspect) the derived profile graph (coauthor, COI, and advising edges) over every profile in a store.

rp graph build [--root ROOT] [--json]
Argument / flagDescription
--root ROOTProfiles root to scan. Default: the resolved cache.
--jsonEmit the build summary as JSON.

Writes <root>/.cache/graph.sqlite.

Terminal window
$ rp graph build --root ~/researcher-profiles
built graph over 12 profiles: 34 nodes, 58 edges -> /home/jane/researcher-profiles/.cache/graph.sqlite

Exit codes. 0 ok · 2 an unknown graph subcommand was given (build is the only one today).


Push, pull and inspect profiles in a SQL profile store. The store is a peer backend: a profile in it is a profile, and rp db pull reproduces the directory byte for byte. Pick one backend per deployment. Nothing should read the same profile from a directory and from the store at once.

rp db init [--database-url URL] [--json]
rp db push <ref>... | --all [--database-url URL] [--root DIR] [--include-binary] [--json]
rp db pull <rid|slug> [--database-url URL] [--to DIR] [--with-build] [--json]
rp db list [--database-url URL] [--json]
rp db rm <rid|slug> [--database-url URL] [--json]
OptionDescription
--database-urlStore URL. Default: $RESEARCHER_PROFILES_DATABASE_URL.
--root (push)Profiles root to read from. Default: the resolved cache.
--include-binary (push)Store binary artifact bodies too (.cache/embeddings.sqlite can be tens of MB).
--to (pull)Destination directory. Default: ./<slug>.
--with-build (pull)Also write the build sidecar, which is never part of the published record.

The URL resolves, highest precedence first: --database-url, then $RESEARCHER_PROFILES_DATABASE_URL. There is no built-in default, because a guessed database is a whole profile store written somewhere nobody meant. A command with no URL exits 2 (invalid usage). Every command prints which database it acted on.

Terminal window
$ export RESEARCHER_PROFILES_DATABASE_URL="postgresql://user@host/db"
$ rp db init
created the rp_* tables on postgresql://user@host/db
$ rp db push --all
jane-doe 0000-0002-1825-0097
john-smith 0000-0004-4600-113X
pushed 2 profile(s) to postgresql://user@host/db
$ rp db pull jane-doe --to ./pulled
pulled jane-doe (0000-0002-1825-0097) from postgresql://user@host/db -> pulled

Exit 2 when a reference matches nothing in the store or when no database URL is configured, 1 when at least one profile failed to push.


Upload a built profile directory to a remote API server (PUT /api/v1/profiles/{slug}). The directory’s contents are tarred (dotfiles like .archive/ excluded) and swapped into place atomically on the server; the profile is immediately listable, and matchable when it includes a built .cache/embeddings.sqlite.

Every push first diffs the server’s manifest against the manifest this push would commit, and refuses to delete anything the server holds unless you pass --force. That includes a push whose manifest merely shrinks: fewer entries means fewer artifacts a reader can fetch, even when no single file was named for deletion. Files the archive builder leaves out (withheld fulltext, anything under the retired cache/ directory, paths outside the spec) are reported on stderr.

A push also refuses outright when the local manifest names files this directory does not have. That is the signature of a partial copy of the profile, not of drift to be tidied up, and re-indexing the server from an incomplete directory is how artifacts get deleted. Use rp where to see which root is in force. Every command that resolves a slug or rid now prints the directory it resolved to on stderr, and warns when the same slug also exists under another root in the resolution order.

rp push <profile> [--root DIR] [--url BASE_URL] [--slug SLUG] [--token TOKEN]
[--dry-run] [--force] [--only PATH ...] [--merge | --prune]
[--include-fulltext] [--json]
OptionDescription
profileA built profile directory, a rid, or a slug (see Naming a profile); the directory must contain profile.jsonld.
--root DIRProfiles root to resolve a rid or slug against. Default: $RESEARCHER_PROFILES_ROOT, else ~/researcher-profiles.
--urlServer base URL, e.g. http://localhost:8109. Default: the server you ran rp login against.
--slugTarget slug on the server. Default: the directory name.
--tokenBearer token. Default: RESEARCHER_PROFILES_TOKEN, else the stored login’s key for that server.
--dry-runPrint what the push would add, change, remove, and keep, then stop. Uploads nothing.
--forcePush even though it removes files the server holds.
--only PATH ...Send only these profile-relative files. profile.jsonld travels too, but its manifest is taken from the server, so nothing else changes. Inline sections (name, expertise) still come from the local document; to change one field only, use the server’s metadata edit endpoint (PATCH /profiles/{slug}/metadata). Implies --merge.
--mergeKeep every server-side file this push does not carry.
--pruneDelete every server-side file this push does not carry, including withheld fulltext and the index. Cannot combine with --only or --merge.
--include-fulltextAlso upload sources/papers/ extracted fulltext. Off by default.
--jsonEmit the server’s summary as JSON (with --dry-run, the whole plan).

Modes. With no mode flag the push is a replace: the server ends up with what the archive carries, except that withheld fulltext and the sqlite index are kept when the archive carried none of them. --only and --merge select merge: everything not sent stays. --prune selects prune: everything not sent goes. See the API reference for the server-side rules.

Terminal window
$ rp push profiles/jane-doe --url http://localhost:8109
pushed jane-doe: +12 ~3 -0 (kept 2) name=Jane A. Doe, level=lite, indexed=True
$ rp push profiles/jane-doe --only sources/papers.jsonld --dry-run
source: /home/me/researcher-profiles/jane-doe
target: http://localhost:8109/api/v1/profiles/jane-doe (mode: merge)
dry run: push jane-doe -> http://localhost:8109 (update, merge)
manifest: 11 entries on server -> 11 after push (0 removed)
added 0
changed 1 works 1
unchanged 0
removed 0
kept 10 citations 1, expertise 1, paper_fulltext 2, paper_summary 5, soul 1
respliced 0
changed (1):
sources/papers.jsonld

respliced counts entries the server keeps that the local manifest no longer lists. The server adds them back, so they survive — but only a server advertising the manifest_splice feature (see GET /api/v1/capabilities) does that. Against one that does not, the same paths are counted as removed and the push refuses. A push also asks for the server’s push_modes before it writes, so --merge or --prune against a server that predates ?mode= is a refusal rather than a silent replace.

After a real push, the server’s own post-commit count is printed, and a count that fell is a warning on stderr:

Terminal window
$ rp push profiles/jane-doe --only sources/papers.jsonld
pushed jane-doe: +0 ~1 -0 (kept 10) name=Jane A. Doe, level=lite, indexed=True
server now holds 11 artifacts: citations 1, expertise 1, paper_fulltext 2, paper_summary 5, soul 1, works 1

Pushing with an API key. On a server with per-part access, a key that may not replace whole profiles may still push to a profile it may write. The server compares the push with the profile it replaces, part by part, and lands it only if every part it changes is Write in the key’s table. Otherwise it writes nothing and rp push names what is in the way:

Terminal window
$ rp push profiles/jane-doe --url http://localhost:8109 --token <key>
push refused [insufficient-access]: this key may not change every part this push touches
needs Write on: summary
next: --only <file> to send only parts you may write, or have the account holder widen this key's access

A change no part covers (what the public sees, a field such as provenance_note) is listed on its own needs "Replace whole profiles" for: line: only a key with that switch may make it. rp push --only <file> sends one file and keeps the rest, so a key with Write on paper_summary can update one summary. The server says what the key’s table holds; a host plugin for rp may print it.

Refusals. Every refusal is one push refused [<code>]: <what> line, then any paths or parts involved, then a next: line with the flag or command to run. The codes are would-remove, manifest-shrink, partial-copy, legacy-cache, mode-unsupported, mode-unknown, and insufficient-access. In Python they are PushRefused.code, .details, and .fix.

Exit codes. 0 ok · 1 the server could not be reached or would not answer, or refused the push part by part (403 insufficient_access) · 2 no server URL resolved, the directory is unreadable, the push would remove server files (or shrink the manifest) and --force was not given, the local manifest names files that are not on disk, --prune was combined with --only/--merge, the server does not support the requested mode, or the target refused the upload. See the API reference for validation rules.


Render a single profile’s index.html in place (at public), and refresh its manifest (hasPart / subjectOf in profile.jsonld). A profile folder is not itself safe to upload: to deploy, use publish.

rp render <profile> [--root DIR] [--base-url URL] [--no-index]
Argument / flagDefaultDescription
profilenonePath to a single profile directory.
--base-url URLnoneBase URL used when building links in the rendered page.
--no-indexoffAdd noindex directives so crawlers skip the rendered page.

Exit code 2 (with a message on stderr) when the profile directory cannot be found.


Write the static tree one audience may see into an output folder. For each profile that audience may see, profiles/<slug>/ gets a profile.jsonld with every section above the audience removed, only the files the audience may read, and an index.html and embeddings/ built for the audience. The collection files (as site writes them) are built for the same audience. Upload is a separate step: the folder needs no filtering.

rp publish <profiles> --out DIR [--who {public,limited,private}] [--root DIR]
[--base-url URL] [--no-index] [--now ISO8601]
[--change-audience] [--dry-run] [--json]
Argument / flagDefaultDescription
profilesnoneA profiles root, or one profile (a directory, a rid, or a slug).
-o, --out DIRnoneOutput folder. Required. Must be new, empty, or a previous rp publish output.
--who TIERpublicThe audience. limited and private exports also get noindex pages and a disallow-all robots.txt. private prints a warning: it holds what only the owner may see.
--base-url URLnoneBase URL for canonical links, sitemap.xml and robots.txt.
--no-indexoffAdd noindex directives.
--now ISO8601nowPin timestamps for deterministic output.
--change-audienceoffAllow --who to be wider than the audience the folder was last published for (recorded in .rp-publish.json). Without it, widening is refused; narrowing is always allowed.
--dry-runoffPrint, per profile, what ships and what is withheld and why, plus skipped profiles. Writes nothing.
--jsonoffPrint the same as data.

A re-run into the same folder removes files the previous run wrote and this one does not, so a tier tightened since the last export disappears.

Terminal window
rp publish ~/researcher-profiles --out ./_site
aws s3 sync ./_site s3://my-bucket/ --delete

Exit code 1 when a profile’s derivedFrom chain does not resolve (a restriction that cannot be computed is refused, not guessed) or the output folder holds files rp publish did not write. Exit code 2 when the profiles folder cannot be found.


Write the collection files for a set of profiles into an output directory: index.json, by-rid.json, SKILL.md, style.css, sitemap.xml, and the rest, at public. site produces the collection-level index only; to build a deployable folder, use publish, which includes these files.

rp site <profiles_dir> --out DIR [--base-url URL]
[--no-index] [--now ISO8601]
Argument / flagDefaultDescription
profiles_dirnoneRoot directory containing the profile subdirectories.
-o, --out DIRnoneOutput directory for the collection files. Required.
--base-url URLnoneBase URL for sitemap.xml and robots.txt.
--no-indexoffAdd noindex directives.
--now ISO8601nowPin timestamps for deterministic output.

Any warnings are printed to stderr. Exit code 2 (with a message on stderr) when the profiles directory cannot be found.


Pull one or more profiles from a registry into the local cache ($RESEARCHER_PROFILES_ROOT, default ~/researcher-profiles).

rp install <slug> [<slug> ...] [--url URL] [--root ROOT] [--token TOKEN]
[--force] [--json]
Argument / flagDefaultDescription
slugnoneOne or more profile slugs to install.
--url URL$RESEARCHER_PROFILES_REGISTRY_URL, else the rp login serverRegistry base URL(s), comma-separated.
--root ROOT$RESEARCHER_PROFILES_ROOT, else ~/researcher-profilesLocal profiles root.
--token TOKEN$RESEARCHER_PROFILES_TOKEN, else the stored login’s keyBearer token, for a private registry.
--forceoffRe-download even if already cached.
--jsonoffEmit one JSON record per requested slug.
Terminal window
$ rp install voss-elena jane-doe --url https://profiles.example.org
installed voss-elena (Elena Voss, level=lite, no fulltext) -> /home/jane/researcher-profiles/voss-elena
voss-elena: already installed at ... (--force to replace)

Exit codes. 0 every slug installed (or already present) · 2 at least one slug failed (a bad registry, a missing token, or a slug the registry does not have); the command keeps going and reports each failure, then exits 2 if any occurred.

Requires the [client] extra.


Print the local path of an already-installed profile, by directory slug.

rp seek <slug> [--root ROOT] [--json]
Argument / flagDescription
slugDirectory slug of an installed profile.
--root ROOTLocal profiles root.
--jsonEmit {slug, path, root} as JSON.
Terminal window
rp seek voss-elena
cat $(rp seek voss-elena)/profile.jsonld

seek takes a directory slug only. To resolve an ORCID or a rid, use where.

Exit codes. 0 ok · 2 no such slug is installed in the resolved root.


List the profile slugs present in the local cache. Core; no extra required.

rp list [--root ROOT] [--json]
Argument / flagDescription
--root ROOTLocal profiles root.
--jsonEmit {root, profiles} as JSON.
Terminal window
rp list
rp list --root ~/work/som/profiles

An empty cache prints no profiles installed in <root> (to stdout in text mode) plus, on stderr, which root was used and how to populate it. Exits 0 either way.


List the profiles available on one or more registries.

rp listr [--url URL] [--token TOKEN] [--json]
Argument / flagDefaultDescription
--url URL$RESEARCHER_PROFILES_REGISTRY_URL, else the rp login serverRegistry base URL(s), comma-separated.
--token TOKEN$RESEARCHER_PROFILES_TOKEN, else the stored login’s keyBearer token, for a private registry.
--jsonoffEmit one JSON record per remote profile, tagged with which registry answered.
Terminal window
rp listr --url https://profiles.example.org
RESEARCHER_PROFILES_REGISTRY_URL=https://profiles.example.org rp listr --json

Exit codes. 0 ok · 1 every named registry failed to answer (returning 0 with an empty list would read exactly like “the registry is empty”, which is not what happened) · 2 no registry URL was given and none is stored.

Requires the [client] extra.


Print the consumer skill (the one that teaches an agent to read a published profile), or install the whole skill tree to a local directory.

rp skill [--print | --install] [--dir DIR]
FlagDescription
--printPrint SKILL.md to stdout. Default.
--installInstall the skill files to a local directory.
--dir DIRInstallation directory. Default: ~/.claude/skills/researcher-profile/.
Terminal window
rp skill
rp skill --install
rp skill --install --dir ~/.claude/skills/researcher-profile

Exit codes. 0 ok · 2 the packaged SKILL.md could not be found (a broken install).


Resolve a rid or a directory slug to the profile’s local directory. A directory name is a display handle, never an identity; where is how you go from either one to a path.

rp where <ref> [--root ROOT] [--json]
Argument / flagDescription
refA rid (ORCID or local:...) or a directory slug.
--root ROOTProfiles root.
--jsonEmit {ref, path, root} as JSON.
Terminal window
rp where voss-elena
rp where 0000-0002-1825-0097

Exit codes. 0 ok · 2 nothing in the resolved root matches ref.


Mint a local: rid for a researcher who has no ORCID.

rp mint-local-id --name NAME
FlagDescription
--name NAMEThe researcher’s name. Required.
Terminal window
$ rp mint-local-id --name "Elena Voss"
local:elena-voss-4a2fd1

Exit 0 always; there is no failure path.


Show, regenerate, or check a profile’s manifest: the hasPart / subjectOf entries in profile.jsonld that enumerate every artifact the directory contains.

rp manifest <profile> [--root DIR] [--write] [--force] [--check] [--json]
FlagDescription
(none)Print one line per manifest entry (role and contentUrl).
--writeRegenerate the manifest by walking the directory and store it back into profile.jsonld. Prints the diff first and refuses to drop entries.
--forceWith --write, write even though the new manifest drops entries.
--checkExit 4 when the recorded manifest disagrees with what is on disk.
--jsonEmit the manifest entries and any drift as JSON (with --write, also before, after, added, dropped).

Drift is reported in both directions: files on disk that the manifest does not list (manifest_unlisted), and manifest entries whose file is gone (manifest_stale). They want opposite actions. An unlisted file wants the manifest regenerated. A dangling entry may mean this directory is a partial copy of the profile, and regenerating there deletes the missing artifacts for good — so --write names every entry it would drop and refuses without --force.

Terminal window
$ rp manifest profiles/jane-doe
soul personality/SOUL.md
expertise personality/expertise.md
works sources/papers.jsonld
paper_summary sources/summaries/doe2019methods.summary.md
$ rp manifest profiles/jane-doe-partial --write
manifest: 141 -> 88 entries (+0 -53)
would drop: sources/papers/doe2016example.md
...
refusing to drop 53 entries; re-run with --force if these files are really gone.

Sign a profile’s profile.jsonld with a key_signature proof (detached JWS over an Ed25519 key) and (re)write the well-known JWK Set beside it.

rp sign <profile> [--root DIR] [--key KEY] [--base-url BASE_URL] [--gen-key]
FlagDefaultDescription
profilenoneA profile directory or its profile.jsonld, a rid, or a slug (see Naming a profile).
--key KEY<profile>/.keys/signing.pemEd25519 private-key PEM. Generated (mode 0600) if absent.
--base-url BASE_URLthe document’s urlPublished base URL, used to build verificationMethod (.../.well-known/...#kid).
--gen-keyoffGenerate a fresh signing key even if one already exists.
Terminal window
rp sign $(rp where voss-elena)
rp sign ./profiles/voss-elena --base-url https://profiles.example.org/voss-elena

The private key lives at <profile>/.keys/signing.pem; .keys/ is always excluded from a publish (see Privacy), so keep it there rather than moving it into the published tree. A document already carrying a key_signature proof has that one proof replaced; other proof kinds are left alone.

Exit codes. 0 ok · 2 no profile.jsonld at the given path, no base URL resolved (pass --base-url or set the document’s url), or the document fails schema validation before signing (refuses to sign an invalid document).

Requires the [signing] extra.


Verify the key_signature proof(s) on a profile.jsonld against a JWK Set.

rp sign-verify <profile> [--root DIR] [--keys KEYS]
FlagDefaultDescription
profilenoneA profile directory or its profile.jsonld, a rid, or a slug (see Naming a profile).
--keys KEYS<profile>/.well-known/researcher-profile-keys.jsonJWK Set path.
Terminal window
rp sign-verify $(rp where voss-elena)
rp sign-verify ./profiles/voss-elena --keys ./keys/researcher-profile-keys.json

Exit codes. 0 at least one proof verifies · 1 a proof is present but none verifies · 2 no profile.jsonld at the given path, or no JWK Set to check against.

Requires the [signing] extra.


Log in to a profile server from the command line and store a login that renews itself. rp reads the server’s OAuth metadata (GET /.well-known/oauth-authorization-server) and signs in as the registered OAuth client rp-cli, asking for the profiles:push scope, in one of two ways:

  • In your browser (the default). rp opens your browser at the server’s sign-in page and listens for the answer on a one-time address on this computer, http://127.0.0.1:<random port>/callback, protected with PKCE (RFC 8252). You sign in and press Allow; the command finishes by itself.
  • With a code (--device). rp prints a link and a short code, and polls until you approve the request in a browser on any device (RFC 8628). rp picks this on its own over SSH (SSH_CONNECTION or SSH_TTY set), on Linux with no display (no DISPLAY or WAYLAND_DISPLAY), when it cannot open a browser, and when the server offers no browser sign-in, as long as the server offers a login code. Without one, rp prints the sign-in link instead and waits for you to paste back the address the browser ends on.

The login code is snapgate’s shared OAuth client, vendored inside rp-sdk; it needs only the Python standard library.

See Command-line login for the wire protocol.

rp login [server] [--label NAME] [--device] [--no-browser] [--json]
Argument / flagDefaultDescription
serverthe server most recently logged in toServer base URL, e.g. https://profiles.example.org.
--label NAMEthe machine’s hostnameName for this machine on the server’s approval page and its list of logins (“rp CLI on NAME”).
--deviceoff (automatic over SSH or with no display)Approve with a link and code on any device.
--no-browseroffDo not open a browser here. Open the printed link in a browser on any computer; when it ends on a page that does not load (127.0.0.1), paste that page’s address back and press Enter.
--jsonoffEmit {url, orcid, name} as JSON.
Terminal window
$ rp login https://profiles.example.org
Opened your browser to sign in. If it did not open, visit:
https://profiles.example.org/oauth/authorize?response_type=code&client_id=rp-cli&...
Waiting for you to sign in...
logged in to https://profiles.example.org as Jane Doe; login stored in ~/.config/researcher-profiles/credentials.json
Terminal window
$ rp login https://profiles.example.org --device
Open this link in your browser to approve the login:
https://profiles.example.org/device?code=WQTX-9F4K
Code: WQTX-9F4K
Waiting for approval...
logged in to https://profiles.example.org as Jane Doe; login stored in ~/.config/researcher-profiles/credentials.json

The login is written to ~/.config/researcher-profiles/credentials.json with mode 0600 (or under $XDG_CONFIG_HOME when that is set), one login per server. Each holds an access token that lives about an hour, a refresh token, when the access token expires, where to renew it, and who you are:

{"version": 1, "logins": {"https://profiles.example.org": {
"server": "https://profiles.example.org", "issuer": "https://profiles.example.org",
"client_id": "rp-cli", "token_endpoint": "https://profiles.example.org/oauth/token",
"access_token": "...", "access_expires_at": 1767225600, "refresh_token": "...",
"extra": {"orcid": "0000-0002-1825-0097", "name": "Jane Doe"}, "ended_at": null}}}

Each login is offered only to the server it came from, and it can upload only the profiles you own or edit. Commands renew the access token on their own when it has expired, and once more if the server answers 401; each renewal saves the new refresh token (the server issues a new one every time) with an atomic write under a lock, so two rp commands at once do not trip over each other. Once it is stored, push, rp install, rp listr and whoami need no flags at all. A server SHOULD list the login where it lists your credentials (“rp CLI on

A credentials.json written by an older rp is no longer read: commands say it is from an older version and to run rp login, which replaces it.

A server that does not offer command-line login publishes no OAuth metadata (or none naming a device endpoint), and the command fails at once with does not offer command-line login. If you decline the request in the browser, or it expires before you approve it, the command stops and says so; run rp login again to start over. If the login later expires (unused too long) or is revoked, commands stop with one sentence, rp: Your rp login for <server> has ended (signed out, revoked, or unused too long). Run rp login again., and the file keeps a note of when and why it ended.

Exit codes. 0 ok · 1 the server refused, you declined, the request expired or timed out, or the server was unreachable · 2 no server URL was given and none is stored.

Needs no extra. When the server’s metadata lists a whoami_endpoint, rp asks it who you are and names you in the message.


Sign out: revoke a stored login at the server (RFC 7009 revocation_endpoint), then delete it. With no argument, the server most recently logged in to.

rp logout [server]
Terminal window
$ rp logout
logged out of https://profiles.example.org; removed it from /home/jane/.config/researcher-profiles/credentials.json

If the server cannot be reached, or offers no revocation endpoint, the login is deleted anyway and rp says to revoke “rp CLI” in your account settings there. Exits 0 whether or not a login was there; with nothing stored it prints not logged in (nothing stored).


Ask a server who the stored token belongs to. rp calls the whoami_endpoint the server lists in its OAuth metadata (see the spec’s command-line login); a server that lists none does not say.

rp whoami [--url BASE_URL] [--token TOKEN] [--json]
OptionDefaultDescription
--urlthe stored login’s serverServer base URL.
--tokenRESEARCHER_PROFILES_TOKEN, else the stored login’s access token (renewed if expired)Bearer token.
--jsonoffEmit the server’s identity record as JSON.
Terminal window
$ rp whoami
https://profiles.example.org: Jane Doe (0000-0002-1825-0097)
can push:
jane-doe 0000-0002-1825-0097 owner

The can push list appears when the server’s answer has a profiles list.

Exit codes. 0 ok · 1 the server rejected the token, could not be reached, or lists no whoami_endpoint · 2 nothing is logged in and no --url/--token was given.

Needs no extra.