Architecture
How ipm turns a list of repos into a searchable catalog of agent artifacts, builds packs of picked items into Claude Code plugins, and why it is built this way. For day-to-day use see the guides; for the sources files see the sources reference. The full design is the ipm design.
Purpose and boundaries
ipm lets a person find agent artifacts — skills, agents, commands, hooks, MCP servers, plugins, Copilot plugins and marketplaces — across many upstream repos. It owns four things: the user’s sources (a pnpm project listing repos of interest), the catalog built from them, a local search index over the catalog, and packs — named sets of picked items in a project’s ipm.toml — which ipm pack build builds into Claude Code plugins and a marketplace under .ipm/.
It deliberately does not own fetching (pnpm does that) or upstream content (read in place, never modified or executed). It installs into Claude Code only when asked: ipm install claude (or an install from the TUI) runs claude plugin commands that the user sees first.
System overview
flowchart LR
subgraph home["$IPM_HOME"]
PJ["sources/package.json<br/>one git dependency per repo"]
LK["sources/pnpm-lock.yaml<br/>pinned commits"]
NM["sources/node_modules/<alias>"]
ST[("store/<br/>pnpm content store")]
end
subgraph cache["$IPM_CACHE_DIR"]
CAT["catalog/*.jsonl"]
GH["catalog/github.jsonl"]
TS[("typesense/")]
end
U[("upstream repos<br/>GitHub / git")]
API[("GitHub API via gh")]
SRC["ipm marketplace"] -->|add / remove / import| PJ
PJ -->|ipm catalog sync: pnpm install| LK
U -->|tarball at pinned commit| ST
ST -->|linked| NM
NM -->|ipm catalog rebuild| CAT
LK -->|commit per source| CAT
API -->|ipm marketplace stats| GH
CAT -->|load, by sync and rebuild| TS
GH -->|load, by sync and rebuild| TS
TS -->|ipm search| R["results"]
CAT -->|ipm show, show tree, show graph| R
Where things live
Paths are resolved in internal/paths, following pnpm’s conventions. Both roots must be absolute.
| Root | Override | macOS | Linux | Windows |
|---|---|---|---|---|
| Home | IPM_HOME | ~/Library/ipm | $XDG_DATA_HOME/ipm, else ~/.local/share/ipm | %LOCALAPPDATA%\ipm |
| Cache | IPM_CACHE_DIR | ~/Library/Caches/ipm | $XDG_CACHE_HOME/ipm, else ~/.cache/ipm | %LOCALAPPDATA%\ipm\cache |
XDG_DATA_HOME and XDG_CACHE_HOME are honoured on every OS. The home holds what a user would lose — sources/ (package.json, pnpm-lock.yaml, node_modules/, the generated .pnpmfile.cjs), pinned/ (the pin store, the same shape: repos pack builds needed at commits the sources aren’t at) and store/, a pnpm store dedicated to ipm (pnpm’s docs say a store is for mutually trusted users and processes). The cache holds what ipm can rebuild: catalog/, typesense/ and curated/ (the last curated list ipm onboard fetched).
Nothing is kept in the directory ipm runs from, so every command works anywhere. Packs live with the project that uses them (next section); user-scope packs live in $IPM_HOME/global/.
Sources
internal/sources owns sources/package.json: {"name":"ipm-sources","private":true,"dependencies":{…}}, one entry per repo, keyed by an alias (owner--repo, lowercased) with a pnpm specifier (github:owner/repo[#ref] or git+<url>[#ref]). ipm marketplace add, remove and import edit it; adding a repo already listed, or one whose alias collides with another’s, is refused. Checkouts pairs each source with node_modules/<alias> (symlinks resolved) and the 40-character commit in its pnpm-lock.yaml entry; a source missing either is an error that says to run ipm catalog sync.
ipm catalog sync
- Find pnpm (
internal/cli/pnpm.go): pnpm from PATH if its major version is 12 or more, elsenpx -y pnpm@12.10.1; with neither, an error naming pnpm. - Write
sources/.pnpmfile.cjs. - Run
pnpm install(with--upgrade,pnpm update --latest) with--ignore-scripts --store-dir <home>/store --dir <home>/sources. - If pnpm fails, print its output and exit 1; the catalog is left as it was. Otherwise run
ipm catalog rebuild’s indexing step, which also loads the search index when the search server is up (and prints a hint to runipm catalog servewhen it is not).
Why the pnpmfile
pnpm fetches a GitHub dependency as a codeload tarball but treats it as a git-hosted package and re-packs it with npm’s packing rules (package.json files, .npmignore). For repos that are also npm packages that strips the agent content — nizos/tdd-guard went from 429 files to 3. The generated .pnpmfile.cjs does two things:
- a custom fetcher (pnpm 12’s
fetchershook,{ delegate }form) that fetches the same commit as a plainhttps://github.com/<owner>/<repo>/archive/<sha>.tar.gztarball, which pnpm unpacks as it is; - a
readPackagehook that empties every source’sdependencies,optionalDependenciesandpeerDependencies, so their npm dependencies are never installed.
Non-GitHub sources (git+https, git+ssh, git+file) do not match the fetcher and still go through pnpm’s git fetcher, so npm’s packing rules can still drop files from them.
Catalog
ipm catalog rebuild turns the synced sources into a searchable record of what they hold, without copying anything. It reads only sources/node_modules and pnpm-lock.yaml, so the same pins always produce byte-identical files (records sorted by ID, one per line). The catalog is rebuildable and lives in the cache; it is not committed anywhere.
- Walk (
internal/catalog/walk.go) finds markers:SKILL.md,agents/**/*.md,commands/*.md,hooks/hooks.json,.mcp.json,.claude-plugin/plugin.json, Copilotplugin.json,.claude-plugin/marketplace.json. - Parse (
parse.go,plugin.go) turns each into a record: stable ID, repo, commit, path, the frontmatter or JSON verbatim asmeta, a content hash. Frontmatter that is not strict YAML falls back to plainkey: valuelines, with the error kept on the record. - Link (
link.go) decides which plugin owns which component by Claude Code’s own rules — skills add toskills/, agents and commands replace their defaults, hooks and MCP merge — and resolves marketplace entries to local, inline or external plugins. - Build (
build.go) adds arepo:record per source and per external source repo, and the reverse links: each component’splugins, each plugin’smarketplaces. - Stats (
internal/github) are kept apart ingithub.jsonlbecause stars change daily;ipm marketplace statsrefreshes them on demand throughgh, andipm catalog rebuildleaves the file alone.
An external plugin’s catalog ID (plugin:<marketplace repo>#<name>) can be given to ipm marketplace add, which adds the repo the plugin lives in. Record fields are in the catalog spec.
Search
ipm catalog serve runs Typesense 30.2 in Docker as container ipm-typesense on 127.0.0.1:8108, with its data and API key in <cache>/typesense/. If a container from the aihs days (aihs-typesense) exists, serve removes it first, since it holds the port; run ipm catalog rebuild afterwards. Loading (done by ipm catalog rebuild and ipm catalog sync when the server is up) rebuilds one collection, artifacts, from catalog/*.jsonl and github.jsonl: one document per record, with its repo’s stars (an external plugin takes its source repo’s), its owning plugins’ names, and an embedding Typesense computes itself with its built-in ts/all-MiniLM-L12-v2 model. The index is disposable — the catalog is the record — so there is no incremental sync.
ipm search runs hybrid search by default: keyword matching over name (weight 4), repo and owning plugins’ names (3 each), description and tags (2 each), fused with semantic matching on the embedding (Typesense’s rank fusion, 0.7 keyword / 0.3 vector), ties broken by used_by (how many other repos reference the document’s repo), then stars. Names are split on /, - and _. A search naming a field an older index lacks maps to “the search index is from an older ipm — run ipm catalog rebuild”. --keyword turns semantics off for comparison. The SCORE column is the hybrid rank-fusion score × 100 — relative standing within one query, not comparable across queries — or, with --keyword, the keyword score as a percentage of the best hit’s. --explain adds KW (✓ for a keyword match, — for semantic-only) and SEM (semantic similarity, 1 − cosine distance, comparable across queries). --page N pages through results; --all fetches every page, 250 at a time — in hybrid mode that is the keyword matches plus about 100 semantic neighbours. # and + are indexed, so c# and c++ are words. The code is internal/typesense (a plain net/http client) and internal/search (documents, schema, parameters, the results table).
ipm show, ipm show tree and ipm show graph read the catalog only, through internal/catgraph: repos contain their marketplaces, marketplaces list plugins, and plugins own components; repos are roots, so tree --up from a skill stops at its repo. Which other repos’ marketplaces list it is a reference, not containment: ipm show lists it under used by:, and the TUI under e (see References between repos).
Packs, ipm pack build and ipm install
flowchart LR
T["ipm.toml<br/>[packs.<name>] items"]
CAT["$IPM_CACHE_DIR/catalog"]
L[".ipm/ipm.lock<br/>items resolved to commits"]
SRC["$IPM_HOME/sources/node_modules<br/>synced sources"]
PIN["$IPM_HOME/pinned/node_modules<br/><alias>--<sha12>"]
PL[".ipm/plugins/<pack>/"]
MK[".ipm/.claude-plugin/marketplace.json"]
C["Claude Code"]
PK["ipm pack"] -->|new / add / remove| T
T -->|ipm pack build| L
CAT -->|items not in the lock| L
L -->|source at the pinned commit| SRC
L -->|else pnpm install, once per pin| PIN
SRC -->|hard link or copy| PL
PIN -->|hard link or copy| PL
L -->|plugin.json, whole-plugin entries| MK
PL --> MK
MK -.->|ipm install claude| C
Scope
internal/project finds the scope root: in a project, the nearest ancestor of the working directory holding ipm.toml (ipm pack new creates ipm.toml in the working directory if there is none); with -g, $IPM_HOME/global. Generated files go in <root>/.ipm/.
ipm.toml and ipm pack
[packs.csharp-sdlc]
description = "C# development lifecycle"
items = [
"skill:wshobson/agents:plugins/dotnet-contribution/skills/dotnet-backend-patterns",
"plugin:anthropics/claude-plugins-official#stripe",
]
Items are catalog IDs and carry no commit. ipm pack add checks each against the catalog. Pickable: skills, agents and file-based commands (placed in the pack’s plugin), Copilot plugins (their skills are added), and whole plugins — local, inline or external — which become dependencies. Hooks, MCP servers and inline commands are not pickable on their own: pack add refuses and prints the ipm pack add <pack> <plugin id> that adds the owning plugin. Repos and marketplaces are refused with a pointer to ipm show tree <id>. Pack names follow the naming rules in internal/pack, which also refuse names Claude Code reserves (claude-…, anthropic-…, official beside claude or anthropic). Removing hooks or MCP servers from a whole plugin is left for later.
ipm pack build
- Resolve (
internal/assemble): every item from.ipm/ipm.lockfirst, then the catalog; an item in neither is an error that names it. Existing pins are kept;--updatere-resolves every item from the current catalog. - Plan (
assemble.Build): each pack’s files and whole-plugin dependencies. Refuses unpickable types, items with no usable name, and marketplace entry names that clash with each other or with a pack; warns about external plugins whose upstream entry names no commit. - Find the repos (
repoFolders,internal/cli/pinned.go): each repo the plan reads (github:Owner/Repo#<sha>orgit+<url>#<sha>) resolves to a folder in your home, never in the project. A synced source at exactly that commit (sources.Installed, which skips unsynced sources) is used as is: no pnpm, no network. Otherwise the repo comes from the pin store,$IPM_HOME/pinned: one pnpm project shared by every scope, whosepackage.jsondependencies are<alias>--<sha12>→ the pinned spec. Pins it lacks are added (sources.AddPins; entries are never removed),.pnpmfile.cjsis written there and pnpm runs once, exactly asipm catalog syncdoes, with--dir $IPM_HOME/pinned. A pin already inpinned/node_modulesruns no pnpm, so a second project with the same pins builds offline. The update holds an exclusive lock onpinned/.lock(flock,LockFileExon Windows), so builds in two projects at once don’t both run pnpm or corrupt the store; a build that waited re-checks before fetching. A pnpm failure prints pnpm’s output and✗ pnpm failed, exits 1 and changes nothing in.ipm/. Pins are per repo, not per item: the fetcher hook fetches whole commits, and the store shares them anyway. The pin store is never pruned. Then the build removespackage.json,pnpm-lock.yaml,.pnpmfile.cjsandnode_modules/from.ipm/(older builds fetched there) and writes.ipm/.gitignore(build.stamp,plugins/,plugins.tmp/,.gascity/). - Place: build into
.ipm/plugins.tmp/<pack>/, putting each picked skill folder, agent and command there (skills/<name>/,agents/<name>.md,commands/<name>.md) by hard link from the repo’s folder, copying (with the file’s mode) when linking fails across volumes. Symlinks are resolved first and every source must stay inside its repo. A name clash within a pack gets-<repo owner>appended, then-2,-3. When every pack is placed,plugins.tmp/replacesplugins/. - plugin.json:
name= pack,description(ipm pack <name>when the pack has none:claude plugin validate --strictwants one),version0.0.0-ipm,author(gituser.nameoripm),dependencies(the pack’s whole-plugin entries), andmetadata.ipm.items—{id, type, name, repo, path, sha, file}per placed file, the provenance. - Marketplace and lock:
.ipm/.claude-plugin/marketplace.json, named<project dir>-ipm(user scopeipm-global), listing each pack as./plugins/<pack>and each whole plugin as<pack>-<name>, pinned to its commit (a local plugin as agit-subdirsource, an inline or external one as its upstream entry), all taggedipm. Built byinternal/marketplace. Then.ipm/ipm.lock(TOML, one[[item]]per picked item — id, type, name, repo, path, commit, and a whole plugin’s marketplace source — sorted by id) is written last, so a failed build leaves the previous plugins and lock in place. - Validate:
claude plugin validate --strict .ipm, skipped with a note whenclaudeis not on PATH or with--no-validate; a failure exits 1. - Stamp:
.ipm/build.stamp, written last, is the hex SHA-256 of the packs inipm.toml(name, description and items; install records are left out), theipm.lockbytes just written and the ipm version.ipm install <target>computes the same hash from the currentipm.tomland the existingipm.lock; when it matches and.ipm/plugins/and the marketplace file exist it skips the build and printsbuild is current.--updatealways rebuilds. The stamp is part of the deterministic output.
The build ends by printing Install in Claude Code: ipm install claude and the claude plugin marketplace add line for wiring it by hand.
Two runs give byte-identical output, and items removed from a pack leave no files. ipm.toml and .ipm/ipm.lock are committed by other people, so they are untrusted input: pack names must be valid names, lock paths must be local to their repo, commits must be full SHAs, and a malformed lock entry is an error naming the item. Because the lock and pins are committed, a teammate can run ipm pack build from ipm.toml and .ipm/ipm.lock without a catalog. In Claude Code, picked items load under the pack’s name (<pack>:<item>; commands show as skills); whole plugins keep their own names and are installed as dependencies.
ipm tui
internal/tui is a Bubble Tea model with four tabs: Catalog (tab_catalog.go: search results over the tree, in the search, results, tree and tray focuses), Packs (tab_packs.go), Installs (tab_installs.go) and Marketplace (tab_marketplace.go). tabs.go holds the title line, tab bar, stats strips and footers; table.go and filter.go are the table and / filter the three newer tabs share; keys.go has each tab’s help. Modals cover the pack picker, the install form (install_form.go), build, install and sync output (install.go) and help. It depends only on six interfaces in deps.go; internal/cli/tui.go implements them:
| Interface | Implementation |
|---|---|
Searcher | Typesense (the same search.Query as ipm search, 50 hits) when the server answers and its index has documents; otherwise keyword matching over the catalog: 4 per query word in the name, 1 in the description and in tags, ties by stars. ctrl+r checks again. |
Catalog | catgraph: Node(id) with its fields, stars, and parents and children with their descriptions. |
Packs | createPack, addToPack, removeFromPack: the functions behind ipm pack, so the same pick rules apply; List reads the scope’s packs with their build state and recorded installs, and the global packs too when asked. |
Builder | runBuild (ipm pack build) with its output piped into the modal line by line. |
Installer | claudeDefaults, cleanTarget, planDeploy and the installers table (installClaude, installGascity): the code behind ipm install claude and ipm install gascity; List is tuiInstalls, the recorded installs. |
Marketplace | The curated lists (curated.Fetch, curated.Parse) with the followed sources and the catalog’s item counts; sourceAdd and sourceRemove follow and unfollow; runSync is ipm catalog sync. |
Global packs and installs, listed from a project on request, are read-only there: changing them needs ipm tui -g. The TUI reads the catalog and the search index once, so after a sync from the Marketplace tab it asks you to restart.
The tree (tree.go) is a pure function, buildTree(catalog, id, chosen): the path from the node’s root to its parent (following the first parent, or the one chosen in tree mode), its siblings, its children and its other parents (“also in”). The renderer cuts names to 24 cells and type labels to 6, so descriptions start in one column; the cursor’s description wraps to three lines; long lists scroll around the cursor. Searches run 150 ms after the last keystroke and carry a sequence number, so a slow result for an old query is dropped. Pack edits write ipm.toml at once; .ipm/ changes only on build. NO_COLOR strips colour from the finished screen.
ipm install claude
ipm install claude [pack] (and the TUI’s install form) builds when .ipm/ is stale (see the stamp above), then plans and runs claude commands through System.Output: claude plugin marketplace add <project>/.ipm --scope S always (Claude Code accepts a repeat, and the marketplace must be declared at the target scope), claude plugin marketplace update <name> when claude plugin marketplace list --json already lists it, then claude plugin install <pack>@<name> --scope S, or claude plugin update when claude plugin list --json shows it installed at that scope and folder. Project and local scope run in the chosen folder. The marketplace name comes from marketName (the same function ipm pack build uses), so the TUI can show the commands before the first build. --dry-run prints those commands without building, running or recording anything. After a successful install the command records it: [[packs.<name>.installs]] entries in the project’s ipm.toml (paths inside the project stored relative to it) for project packs, and $IPM_HOME/installs.json (a sorted JSON array with absolute paths) for user-scope packs. Reinstalling the same pack to the same target, path and Claude scope replaces its record; project and local scope at one folder are two records. Bare ipm install replays every recorded install in the scope, and ipm pack show lists a pack’s installs. Claude Code reads an installed ipm pack in place from .ipm/plugins/<pack>; a rebuild takes effect at its next session.
References between repos
catalog.BuildRefs finds every external entry — a marketplace entry whose source_repo is another repo — and resolves what it references: the source repo’s plugin at the entry’s source path, else the one with its name, else the repo’s only plugin, else the repo record. A source repo that isn’t one of the user’s sources only has a placeholder record (followed: false), which is never a target, so the entry is unresolved. catgraph keeps these apart from containment: repos never have parents, RefTarget, RefsTo, RefsInto and UsedBy answer the reference questions, and ipm show tree stops at an external entry with a ↗ <target> line. internal/search uses the same function for each document’s used_by and external fields. In the TUI, → on an external entry jumps to its target with a back stack drawn as a second root, and e lists a repo’s references.
ipm onboard and ipm uninstall
ipm onboard (internal/cli/onboard.go) chains existing commands. It first looks for data — sources in sources/package.json or a catalog — and keeps it or backs it up by renaming home, then cache, to <dir>.backup-YYYYMMDD-HHMM; if the second rename fails the first is undone. internal/curated fetches the curated list (a URL is cached under <cache>/curated/ and reused when offline; a path is read as is) and groups its [[repos]] rows into lists. The picks come from --lists, --yes (the default lists) or the checklist (internal/tui/checklist.go, its own Bubble Tea model, where sources already followed are locked). It prints the four concepts first (marketplace, catalog, pack, install). Then it calls sources.AddSkippingExisting, syncSources, runStats (skipped without a logged-in gh), and runServe with runLoad (skipped without Docker), stopping at the first failure with the command to rerun.
ipm uninstall (internal/cli/uninstall.go) checks everything before removing anything: it refuses a development build (the binary’s folder is bin and an ancestor holds .git and cli/go.mod) without --force, and with --purge asks for the home path. Then it removes the ipm-typesense container, the binary, and with --purge the home and cache.
ipm doctor
One row each for home, cache, pnpm (the version, and whether it runs through npx), sources (count; a warning if not all are synced), catalog (record count; a warning if not built), docker, typesense and claude (a warning if not on PATH). A missing home or pnpm is a problem and exits 1; warnings do not.
Code layout
cli/
├── cmd/ipm/main.go process wiring only: argv, real commands, HTTP client, exit codes
└── internal/
├── cli/ the Cobra command tree (cli.go), one file per command (packcmd.go, install.go, …); pnpm.go (find pnpm, .pnpmfile.cjs)
├── paths/ IPM_HOME, the cache dir, the folders under them
├── sources/ sources/package.json, aliases, pnpm-lock.yaml commits; AddPins for pinned/package.json
├── catalog/ walk, parse, link, build; *.jsonl read and write
├── github/ GitHub stats through gh
├── typesense/ Typesense HTTP client
├── search/ documents, schema, parameters, results table
├── catgraph/ the catalog as a graph; tree and graph rendering
├── scan/ the folder report behind ipm catalog scan
├── project/ scope root, ipm.toml packs, .ipm/ipm.lock
├── assemble/ resolve picks, plan each pack, place files by hard link
├── pack/ naming rules; reading an aihs-era manifest.toml for marketplace import
├── copilot/ the skills a Copilot plugin.json lists
├── marketplace/ marketplace.json entries (ipm's own or upstream entry maps) and atomic Write
├── curated/ read and write curated source lists ([[repos]] TOML) for ipm onboard and ipm marketplace; fetch with a cached fallback
├── sourcepacks/ maintain source-packs.toml from a wider list of repos (run by cmd/sourcepacks, a maintainer's tool)
└── tui/ the Bubble Tea model behind ipm tui (a file per tab, tree.go, install_form.go), over six interfaces (deps.go); checklist.go, ipm onboard's picker
Commands reach the outside world only through cli.System (stdout, stderr, environment, LookPath, an Output runner for pnpm, docker, gh and claude, an HTTP client and a clock). go install …/cli/cmd/ipm builds the same binary. In a clone, bin/ipm at the root is a shell shim: it rebuilds cli/bin/ipm when a Go source changes and execs it; install.sh at the root builds the checkout into ~/.local/bin/ipm (or $IPM_BIN_DIR), or, piped from curl, runs go install …@latest there; .envrc no longer touches PATH, so the ipm on PATH is the installed one and bin/ipm <command> runs the development build.
Decisions
| Decision | Rationale |
|---|---|
| pnpm is the fetcher, pinner and store | It already solves fetching git dependencies, pinning them in a lockfile, a shared content-addressable store and cheap linking; ipm doesn’t reimplement any of it. |
Always --ignore-scripts; sources’ npm dependencies emptied | Sources are content, not code. No upstream file is ever executed. |
A generated .pnpmfile.cjs fetches GitHub commits as plain tarballs | pnpm’s re-packing with npm’s rules dropped agent content from repos that are also npm packages. Needs pnpm 12’s custom fetchers, hence the pnpm 12 floor and the npx fallback. |
| A dedicated pnpm store under the home | A pnpm store is for mutually trusted users and processes; agent repos are kept apart from a user’s normal store. |
| Sources and catalog per user, in platform data and cache folders | Like pnpm’s registry config and metadata cache: one list of interests per person, usable from any project. The catalog is rebuildable, so it is cache, not data. |
| Catalog kept separate from GitHub stats | The catalog is a pure function of the pinned commits; stars change daily and are refreshed on demand. |
| Disposable search index | The catalog is the record; ipm catalog rebuild rebuilds the index rather than syncing it. |
Packs are built per project in .ipm/, committed except plugins/ | A team shares the pack definition and its pins; anyone rebuilds the same plugin with ipm pack build, without the author’s catalog. |
| Pack builds read repos from the home (synced sources, else the shared pin store), never a per-project pnpm project | A project needs no download when your sources already hold its pins, and projects sharing a pin share one copy. |
| Picked files are hard-linked, not copied or symlinked | No extra disk and no new dependency; Claude Code loads them like any plugin files. Copy is the fallback across volumes. |
| Whole plugins are marketplace dependencies, not copies | They keep their own namespace, hooks and MCP servers, and stay pinned to an upstream commit. |
ipm installs into Claude Code only on ipm install claude | Installing changes the user’s Claude config, so it is an explicit command (or a confirmed step of the TUI’s install form showing the exact claude commands), never a side effect of building. |
| All commands in Go, one test suite | One language, and every command is testable in-process through cli.System. |
Testing
mise run verify runs the format and lint checks, go build, go vet and go test ./...:
internal/pathsresolves defaults and overrides for macOS, Linux and Windows, and refuses relative paths and a missing home.internal/sourcestests aliases, thepackage.jsonround trip, duplicate and colliding repos, and reading commits from a fixturepnpm-lock.yaml, including unsynced cases.internal/projecttests the scope walk and theipm.tomland lock round trips;internal/assembletests resolving from lock then catalog, name clashes, Copilot plugins, whole-plugin entries, hard link falling back to copy, stale files removed, and a repo missing from the folder lookup.internal/catalog,internal/catgraph,internal/searchandinternal/typesensetest the catalog, the graph and search against fixtures and anhttptestserver.internal/clidrives every command in-process with a fakeSystemand tempIPM_HOME/IPM_CACHE_DIR: the exact pnpm arguments, the generated.pnpmfile.cjs, thenpxfallback for an old pnpm, a failed pnpm leaving the catalog intact, doctor rows,ipm packrefusals and scopes, andipm pack buildpins, determinism, installing from the lock alone, validation, reading synced sources without pnpm, fetching into the pin store once (and its lock), and removing old.ipmpnpm files.cmd/ipmbuilds the real binary and checks exit codes.
mise run test:integration adds the tests behind the integration build tag, also in cmd/ipm: with real pnpm and Node, marketplace add, catalog sync, catalog sync --upgrade and ipm pack build (from synced sources, then from the pin store after the source moves on) against local git repos over file://; with Docker as well, ipm catalog serve, ipm catalog rebuild and ipm search against a real Typesense. CI runs them; locally the search test refuses to run beside an existing ipm-typesense container.
Extending
- A command — add
internal/cli/<name>.gowith anew<Name>Cmd(sys System) *cobra.Command, register it innewRootCmd, and check its arguments withnoArgs,exactArgs,minArgsorverbsso a malformed command line exits 2. Get data folders fromipmPaths(sys). - Another artifact type — add a marker to
internal/catalog/walk.go, a parser, a type incatalog.Typeswith its file, and fixtures. - A pickable type — teach
ipm pack addto accept it (internal/cli/packcmd.go) andinternal/assemblewhere to place it. - A TUI feature — add state and keys to the model in
internal/tui(tested against the fakes inmodel_test.go); if it needs something new from ipm, add a method to one of the six interfaces indeps.goand implement it ininternal/cli/tui.goover existing CLI code.