PURLs
The scheme
A package URL, PURL for short, is defined by ECMA-427:
pkg:type/namespace/name@version?qualifiers#subpath
type picks the adapter. namespace is the npm scope, the Composer vendor or the Arch repository. version is optional everywhere except for a dependency lookup. qualifiers carry one key the library reads, repository_url, which overrides the adapter's base URL. subpath is parsed and ignored.
Parse
import { parsePURL, fullName } from "@agntn/registries";
const parsed = parsePURL("pkg:npm/%40vue/core@3.5.0");
// { type: "npm", namespace: "@vue", name: "core", version: "3.5.0", qualifiers: {}, subpath: "" }
fullName(parsed); // "@vue/core", the name the registry expects
parsePURL is the single parser in the library. Every helper, the CLI and the AI tool go through it, so a PURL that works in one place works everywhere. It percent-decodes each component, lowercases the type, and applies the two normalizations the spec asks for: PyPI names are lowercased with runs of -, _ and . collapsed to -; Arch names are lowercased.
A PURL that cannot be parsed throws InvalidPURLError with the reason: a missing pkg: scheme, an empty type, an empty name, or malformed percent-encoding.
Build
import { buildPURL } from "@agntn/registries";
buildPURL({ type: "cargo", name: "serde", version: "1.0.0" });
// "pkg:cargo/serde@1.0.0"
buildPURL({ type: "npm", namespace: "@vue", name: "core" });
// "pkg:npm/%40vue/core"
buildPURL({ type: "npm", name: "lodash", qualifiers: { repository_url: "https://npm.example" } });
// "pkg:npm/lodash?repository_url=https%3A%2F%2Fnpm.example"
Output is canonical: components are percent-encoded, qualifier keys are lowercased and sorted, and a duplicate or invalid key throws instead of producing a PURL that would parse differently on the way back. Every adapter's urls().purl(name, version) uses it, so a PURL the library prints is one the library reads.
Namespaces by ecosystem
| Ecosystem | Namespace | Example |
|---|---|---|
| npm | scope, with @ | pkg:npm/%40vue/core or pkg:npm/@vue/core |
| Cargo | none | pkg:cargo/serde |
| PyPI | none | pkg:pypi/flask |
| RubyGems | none | pkg:gem/rails |
| Packagist | vendor, required | pkg:composer/laravel/framework |
| Arch Linux | arch or aur, arch by default | pkg:alpm/pacman, pkg:alpm/aur/paru |
fullName joins namespace and name with /, which is what every adapter expects as its registry name. Packagist refuses a name without a vendor with InvalidPURLError. The Arch adapter refuses a namespace other than arch and aur with NotFoundError.
Shorthand in the CLI
The CLI accepts a PURL without its scheme. registries info npm/lodash and registries info pkg:npm/lodash are the same call. The library does not: fetchPackageFromPURL("npm/lodash") throws, because a silent guess about the scheme would hide a typo in the type.
Pointing at another registry
await fetchPackageFromPURL("pkg:npm/lodash?repository_url=https://registry.npmmirror.com");
repository_url becomes the adapter's base URL for that call. The adapter's urls() still point at the public registry, since those are display links, not API calls.
From a PURL to an adapter
import { createFromPURL } from "@agntn/registries";
const [registry, name, version] = createFromPURL("pkg:pypi/flask@3.1.1");
registry.ecosystem(); // "pypi"
name; // "flask"
version; // "3.1.1"
await registry.fetchDependencies(name, version);
createFromPURL is what the helpers wrap. Use it when you want several lookups on one package without parsing the PURL four times.