Skip to content

Multi-provider client

The cartographer binary bundles, besides the server (serve), the client subcommands that connect a machine to a Cartographer server: agents (discovery), connect (configures + materializes provisioning artifacts — skills, agents, hooks, instructions, mcp — D69), disconnect (disconnects, the inverse of connect), status (drift, with per-kind counts), sync (realigns), resolve (resolves a {{repo:}}/{{path:}} placeholder, D75), plus a TUI dashboard when invoked with no arguments in a terminal.

The client always talks to the server over HTTP (the sync_pull tool): there is no stdio transport on the client side, nor a separate binary — see the client decision records for the rationale. Generating the MCP config files (internal/configurator) and the materialization logic (internal/provisioning) are the same used server-side by sync_check/sync_apply (docs/sync.md).

Subcommands

Discovery, version and output formats

Root help groups commands into Get started, Client, Server, Knowledge base and Diagnostics. cartographer version and cartographer --version are aliases; root and service help write to stdout and exit 0. Unknown commands only offer a correction for one command within edit distance two.

agents, status, and service status accept --output table|json (table is the default). JSON is written only to stdout and uses schema cartographer.status/v1: it includes the schema version, effective server URL, client/server facts, provider states and artifact counts; when relevant it also contains native-service facts. State and error code fields are stable for automation; a wrapped low-level cause is retained in JSON only. status keeps exit 0 for in-sync, 1 for drift and 2 for configuration or operational errors. service status retains 0 running, 3 stopped and 4 not installed.

cartographer agents

Lists the supported providers, whether they are installed on the machine (internal/agents.Detect: any of its binaries in PATH (Kiro ships as kiro from the IDE and kiro-cli standalone), a known config directory, or — for a provider with a root of its own — that root, $HERMES_HOME) and whether they are connected (present in the machine-wide .cartographer.yaml, ~/.cartographer.yaml).

cartographer agents

cartographer connect [provider|all]

Generates the MCP config (HTTP transport only), materializes artifacts via sync_pull (the full kind×provider matrix is in sync.md §Kind × provider matrix; combinations with no known destination go to unsupported and are filtered upstream), and writes/updates .cartographer.yaml. If the server is unreachable, the MCP configs and .cartographer.yaml are still written; materialization is deferred (warning, exit 0) — it must be completed with cartographer sync once the server is up. Materialized hooks are also automatically registered in the provider's native mechanism (settings.json / config.toml / JS plugin — sync.md §Agents and hooks); connect/sync print an info line for each one.

Multi-KB servers (D92). connect reads GET /health before emitting MCP configuration. With one mounted KB (or an older single-KB server that omits kbs) it keeps the compatible single entry, <server_name>, pointed at the bare /mcp URL. With two or more KBs it writes one entry per KB, named <server_name>-<kb> and pointed at /mcp?kb=<kb>, and records that KB list in .cartographer.yaml (known_kbs). sync repeats the enumeration: it adds new entries, removes entries for disappeared KBs, and performs the bare↔suffixed rename on one-to-many transitions. If the server cannot be reached, it leaves the MCP entries and known_kbs untouched and warns; run sync again once it is up.

Kiro subagents (D195). Kiro receives KB subagents as JSON configs in ~/.kiro/agents/ (and .kiro/agents/ in workspace scope), which kiro-cli agent list reports as Global/Workspace and the built-in agent delegates to through its use_subagent tool, selecting by description. Its hook cell stays unsupported — the shipped client has no hook mechanism, so its re-sync trigger remains the scheduled timer (interoperability.md §Kiro hooks).

Workspace scope (D193). cartographer workspace bind <provider> <path> --kb <name>… moves a provider from one machine-wide catalogue to one projection per bound repository: the KBs land in that repository's own project-local directories and nothing KB-sourced is written under $HOME any more. connect --workspace <repo> makes the same choice at connect time, which is the only moment it is free — a provider-global connect materializes every selected KB into $HOME first, and moving them afterwards is a migration. workspace unbind removes the declaration and the next sync prunes what was projected there. workspace list shows the bindings, with [gone] next to a directory that is not there any more.

Existing configurations are untouched: the scope is per provider, absent means the historical global catalogue, and no upgrade changes it. Full rules, the project-local destination matrix, the repository-hygiene guarantees and the providers that cannot be scoped at all → sync.md §Workspace scope.

Routed servers (D187). When /health reports mount_mode: routed, the KB is no longer part of the URL: the client writes one entry, <server_name>, pointed at the path the server names in routed_path (/mcp/routed), whatever the provider is bound to. The binding still decides which KBs that provider may use — routing changes the transport, not the authorization — and the generated instructions block names the kb value each KB's tools must be called with. Both facts are persisted in .cartographer.yaml (server_mount_mode, server_routed_path) so doctor and status can derive the expected entries offline.

Switching an existing deployment between the two modes is a reconnect, not a silent rewrite: it changes the shape of every entry, which an incremental sync cannot see. cartographer status reports mount mode changed: … and names cartographer reconnect, the same answer D142 gives to a server-version change. The removal set covers both shapes, so a reconnect leaves no orphan entry from the previous mode.

Kiro and flat tool namespaces (D102). Kiro's MCP tool namespace is flat across servers, unlike Claude Code/Codex/OpenCode which namespace per server: writing 2+ MCP entries for kiro (i.e. connecting to a 2+-KB server) leaves only one KB's tools reachable in a Kiro session unless the server mounts the others with a tool_prefix (docs/deployment.md §MCP tool-name prefix, D102). connect/sync warn on stderr in that case; the operator is expected to add tool_prefix/tool_prefix_mode server-side. The warning stays silent against a routed server: one entry cannot collide with itself, and routing is the other answer to the same problem. Since D120 /health advertises each KB's effective tool_prefix, so the client can see which KBs are already namespaced instead of reasoning from the precondition alone.

Antigravity and the 64-character tool identifier (D201). Antigravity shows each tool as mcp_<server>_<tool> and drops any identifier over 64 characters, silently. connect/sync compute the longest identifier each Antigravity entry would produce — entry name, the KB's effective tool_prefix from /health, the longest tool name — and warn on stderr when it exceeds the limit, naming the entry. The remedies are server-side: a shorter kbs[].tool_prefix, or mcp.mount_mode: routed, whose single entry carries unprefixed tools. Without /health facts the check stays silent.

Prefix discovery (D120). Every client-owned direct tool call — manifest pull during sync, remote reindex, the TUI's status probes — qualifies the tool name with the prefix the server advertises for that KB in /health, never with one re-derived locally from the KB name. A locally derived prefix is a guess: tool_prefix is an arbitrary operator string, so a client that guessed it called tools that did not exist and reported the resulting failure as an unreachable server. The discovered value is used live and never persisted, so changing a prefix server-side needs no client-side reconnect.

cartographer connect                                   # all agents detected on the machine
cartographer connect claude                             # Claude Code only
cartographer connect --agents claude,codex              # selected subset
cartographer connect opencode --server-url http://cartographer.example.com/mcp --auth
cartographer connect claude --pin-key homelab=0123...  # pin a KB Ed25519 public key
cartographer connect all --auto-trust --dry-run
Flag Default Description
(positional) all claude | opencode | codex | kiro | hermes | antigravity | all (all detected agents)
--agents (unset) Comma-separated subset (claude,codex); cannot be combined with the positional provider
--kb (unset) Which KBs this client may receive (repeatable, or comma-separated; all for every mounted KB). Required on a first connect against a server mounting two or more KBs — see below (D190)
--server-url http://localhost:39273/mcp Cartographer server URL
--auth false Enables the Bearer header in generated configs
--token-env CARTOGRAPHER_TOKENS Env var holding the Bearer token
--dry-run false Prints what would be written, in the conditional (would write, would connect), and writes nothing (D147)
--auto-trust false Also treats KB skills as trusted (unsigned)
--pin-key (repeatable) Pins KB=PUBLIC_KEY for Ed25519-verified provisioning artifacts; existing pins are preserved

signing_keys in .cartographer.yaml stores public-key pins per KB. Pins are operator-supplied and are never learned from sync_pull; multiple pins permit key rotation. Pin the new key, switch the server signer, then remove the old pin after all clients have synchronized.

If no provider is detected and no explicit name is passed, the command exits with an error (exit 1) without writing anything.

Interactive form (TTY, D49+D64+D86). With no form flags and in a TTY, the form shared with the TUI opens (connectform.go): each field shows a contextual hint below it when focused ("Token env var" is the name of the environment variable holding the bearer token — the token itself is never written to disk; with Auth off the field is rendered secondary and the hint says it is ignored). In the standalone connect form, the four provider checkboxes are pre-selected from the installed-agent set; select one or more with Space or Enter. The Server URL prefill follows the precedence existing .cartographer.yaml > CARTOGRAPHER_SERVER_URL (client env) > http://localhost:39273/mcp. On submit a probe runs (client.Health, GET /health, 5s timeout, token from env only if Auth is enabled) before writing any file: a reachable server with no mounted KB explains the kb create then service-restart path; otherwise on failure the form is re-shown with the entered values and an inline error (distinguishing a 401 "token rejected" from a network error), with an override available — in CLI a y/N prompt "proceed anyway?", in the TUI a second consecutive Connect with no changes forces the connection. A failed doConnect also re-shows the form populated (connect is idempotent: no disconnect is needed to retry).

Local service (D73). If the probe fails, the URL is loopback (localhost/127.0.0.1/::1), and the native service isn't running, before the y/N override the CLI flow offers to install and start the local service (cartographer service install with defaults, polling /health for up to 10s, then an automatic re-probe). In the non-interactive path, a deferred materialization to a loopback URL only adds a hint on stderr suggesting cartographer service install when unreachable; a reachable 0-KB server instead prints cartographer kb create <name> --remote <url> followed by cartographer service restart. A successful connect prints the absolute paths of generated MCP configs and reminds the user to restart the selected agent sessions to load the MCP tools.

cartographer disconnect [provider|all]

The inverse of connect: for each target provider — default all = every provider connected in .cartographer.yaml — it surgically removes every managed MCP server entry (the bare name and any persisted per-KB suffixed names) from that provider's config file (internal/configurator.Remove, the inverse non-destructive merge: the rest of the file is left intact; if the mcpServers/mcp map ends up empty it is not deleted), prunes the managed artifacts registered for that provider in the lockfile (provisioning.PruneManaged — only managed files, never untracked ones), then removes the provider from the lockfile and from .cartographer.yaml. If the lockfile ends up with no providers it is removed; .cartographer.yaml, on the other hand, is never deleted (D64): with zero agents it stays on disk with agents: [], preserving server_url/server_name/auth/token_env/trust/known_kbs/clients as defaults for the next connect (a disconnect→connect restarts from the previous server, not from http://localhost:39273/mcp).

cartographer disconnect                # every connected provider
cartographer disconnect claude         # Claude Code only
cartographer disconnect --agents claude,codex # selected subset
cartographer disconnect all --dry-run  # preview without writing
Flag Default Description
(positional) all claude | opencode | codex | kiro | hermes | antigravity | all (every connected provider)
--agents (unset) Comma-separated subset (claude,codex); cannot be combined with the positional provider
--dry-run false Prints without removing

Idempotent: exit 0 even if there was nothing to remove (no .cartographer.yaml, provider already disconnected, MCP entry already absent, ...). Exit 2 only on an actual error (I/O, malformed provider config JSON, ...).

cartographer status

Compares the server's manifest revision with the last applied lockfile, for every connected provider. Read-only.

cartographer status

Exit code: 0 all providers in sync, 1 at least one provider in drift, 2 error (no .cartographer.yaml, server unreachable, ...). For every provider it also prints per-kind counts (provisioning.KindCounts), e.g. skill 4/5 · agent 2/2 · hook 1/1. On drift it prints the diff (added/updated/removed, with a trust state: built_in, verified, trusted, approved, approval_stale or needs_approval — see D115 for the MCP-specific approval states). MCP artifacts in needs_approval/approval_stale get their own cartographer approve mcp <name> --kb <kb> hint, separate from the --auto-trust suggestion for the other kinds. Before the artifact report it prints the client and server versions. A non-dev mismatch is a warning only (it does not change the exit code); on loopback, an installed local service also gets a cartographer upgrade-repair hint (D121: it replaces the running service and re-syncs the providers in place, where the former service restart hint only did the first half). For an unavailable endpoint, the table names the configured endpoint once and suggests checking that URL (or cartographer service status for loopback); connected providers are reported as unknown, rather than repeating a network failure for each provider.

Dashboard

With no subcommand in a TTY, the dashboard renders the same status snapshot as status. Its server panel is a labelled block — endpoint with state and readiness, client/server versions, the local native service when one is installed, and the KB inventory with, per KB, how many connected providers are bound to it (a count of bindings: not sessions, and not a confirmation that a sync has run, so kb-tre (0) means the server serves it and nothing consumes it). Enter connects a disconnected provider or syncs a connected one; s syncs the selected provider, S syncs every connected provider one at a time and names the one in flight, d opens the disconnect confirmation — which names the KBs whose artifacts will be removed — and r refreshes. Unavailable actions are omitted from the contextual key map. At 60 columns it uses compact labels and shortened endpoints; 80 is the normal layout and 120 retains full endpoint and artifact detail. Failures keep the current selection and entered connect-form values.

cartographer sync

Re-runs sync_pull and reapplies the manifest for every connected provider: materializes add/update, prunes obsolete artifacts, updates the lockfile. Idempotent.

cartographer sync [--client <provider>]... [--auto-trust] [--dry-run] [--no-heal]
Flag Default Effect
--client (all) Syncs only this provider (repeatable). A provider left out is not touched at all: not its MCP entries, not its artifacts, not its lockfile entry
--dry-run false Prints without writing
--auto-trust false Also treats KB skills as trusted (unsigned)
--no-heal false Reports managed artifacts that diverged on disk instead of restoring them from the server (D139)

Each provider receives only the KBs bound to it (§cartographer client), so the manifest — and therefore the revision — differs per provider. When every targeted provider agrees, sync prints one synced to revision <r> line as before; when bindings made them diverge it prints one line per provider instead, rather than implying an agreement that does not exist. See sync.md §Per-provider projection.

Every sync verifies the managed files on disk, not just the manifest revision, and restores what was edited or deleted locally — the restore is reported on its own line, because it discards someone's local change. See sync.md §On-disk verification and healing.

This is also how a configured provider is repaired in place after a local upgrade: the same in-process runner is what cartographer upgrade-repair calls (D121). Repair in place is the default; a full rebuild is cartographer reconnect (below), for the residues no incremental sync can see. Already-open provider sessions still need to be restarted to reopen the MCP connection.

When the server that answers is not the one this client's state was materialized against, sync prints one line saying so and recommending reconnect — once per invocation, whatever the provider count — and then syncs normally (D142). It reports; it never escalates on its own. An unknown version on either side (a lockfile written before D142, an unreachable server) and a local dev build say nothing.

cartographer reconnect [provider|all]

Rebuilds a provider's configuration from scratch: a full disconnect followed by a full connect, in one invocation, reusing both rather than being a third implementation of either (D142).

cartographer reconnect                 # every connected provider
cartographer reconnect claude          # Claude Code only
cartographer reconnect --agents claude,codex
cartographer reconnect --dry-run       # preview both halves, write nothing
Flag Default Description
(positional) all claude | opencode | codex | kiro | hermes | antigravity | all (every connected provider)
--agents (unset) Comma-separated subset; cannot be combined with the positional provider
--dry-run false Both halves simulate, nothing is written

When to prefer it over sync. Pruning is managed-only, so anything an older Cartographer version wrote under a different name — a generated plugin whose filename changed, a managed block whose marker spelling changed, a hook registered outside the block — is not in the current managed set and survives every sync. The connect half writes the current shape from nothing, which is what removes them. For everything else, sync is the right tool and stays incremental.

What it preserves. Everything in .cartographer.yaml: server URL and name, auth mode and token env, trust, pinned signing keys, MCP approvals, search roots and paths. It is a rebuild, not a reset — a user who had to re-approve every MCP descriptor after an upgrade would simply stop running it. A provider that was not previously connected is rebuilt all the same, stating that it was not.

It is never automatic. No upgrade path invokes it: the reasoning is D121's ("automatic repair never invents an approval, never broadens trust"), and removing and rewriting provider configuration is a bigger hammer than a repair.

Partial failure. If the connect half fails after the disconnect half succeeded, the command exits 2, names the providers now left disconnected, and prints the exact cartographer connect invocation — with the same settings — that finishes the job. Silence there would leave an agent without its MCP endpoint. A --dry-run failure leaves nothing disconnected, so it says nothing.

After a successful rebuild the summary ends with the reminder that already-open agent sessions must be restarted to pick up the rewritten MCP configuration: the one step no client-side command can perform.

cartographer doctor

Read-only diagnosis of this machine's client configuration (D143). status answers "is the applied revision current"; doctor answers the question an operator actually has after an upgrade or a half-finished migration: is there anything left over here that should not be, or missing that should?

cartographer doctor [--json] [--provider claude]
Flag Default Description
--json false Emit the report as JSON (cartographer.doctor/v1) instead of text
--provider (unset) Narrow the run to one provider — inspected even if it is not connected

It never repairs, and never writes — no lockfile migration, no directory creation, no cache refresh. Every finding names a real path on this machine and the command that fixes it (sync, reconnect, connect, service sync-timer install), because a diagnosis nobody can act on is noise and a doctor that silently fixes things is a doctor nobody can predict.

The checks:

Check What it looks at
client-config .cartographer.yaml exists and parses; an agent is connected; every configured provider is still installed
lockfile present, readable, and in the v2 format — a v1 file on disk is migrated in memory by every read, but stays v1 until something rewrites it
managed-files the on-disk verification of D139, per provider: missing, modified, unregistered; plus files sitting inside a managed skill/hook directory that no lock entry accounts for (D178) — reported only, since doctor cannot prove Cartographer wrote them
mcp-entries the Cartographer entries in the provider's native config match the KBs recorded in .cartographer.yaml — an entry for a KB the server no longer mounts, or a missing one
instructions exactly one well-formed managed block per provider that has instructions materialized (begin recognized by prefix, so a block written by an older version still counts), and that the provider actually reads the file it was written into (D189)
hooks one native registration per managed hook — the D99 double-fire is a registration left outside the managed block by Codex's own rewrite
server /health reachable; the recorded server_version (D142) against the live one; client binary against server. When an unreachable server is loopback and no local native service is installed, the finding names that cause and the two remedies instead of pointing at service status, which would only repeat installed: false (D174)
trigger every connected provider has a session hook, or the scheduled trigger is installed (D140)
capability every per-KB gate the server advertises on /health is on, and no KB was mounted by discovery rather than by a kbs[] entry (D151). Info severity: it names the setting that would change it
symlink no managed destination directory is a symlink — provisioning refuses to write through one, so the artifacts it would hold are not installed (D148)
kb-collisions no two KBs bound to the same provider claim one kind+name (D171). sync refuses outright when they do, so a machine that has not synced since the binding changed would otherwise show no symptom. Silent when the server is unreachable
unbound-residue no managed file comes from a KB no longer bound to the provider holding it (D170) — a projection predating an unbind, or a hand-edited lockfile. Only for providers with an explicit binding; a file with no recorded source (a lockfile written before D170) is unknown, not wrong, and never reported

Severities. error — something is broken now (a managed file missing, a hook firing twice); warning — something is stale or suboptimal (v1 lockfile, no trigger for a hook-less provider, a version difference); info — context that cannot be acted on by itself and never changes the exit code (managed entries recorded before content hashes existed, which nothing can verify). Findings are printed errors first.

doctor --repair-hashes re-records the materialized hash of every managed entry that has none, computing it from the bytes already on disk (D157). It exists because the previous remedy for that narrow gap was reconnect, which prunes and rewrites every managed artifact on every client — in the field ~150 file operations to backfill six hashes, with a partial failure leaving both clients without skills. A backfilled entry is marked adopted_at in the lockfile: it is adopted, not verified — nothing was compared against the server, so drift is detectable only from the next server-side change onward. An entry whose file is missing is left alone: that is real drift for sync to fix, not something to paper over.

Exit codes: 0 clean, 1 findings (error or warning), 2 an error running the command — the same convention status uses. An unreachable server is one warning finding, not a failure: doctor stays useful offline.

JSON shape: {schema_version, error_count, warning_count, info_count, findings[]}, each finding {check, severity, message, path, fix}. Flat and stable — it ends up in someone's monitoring.

The bootstrap hook and the scheduled timer deliberately do not run it: it is an operator command, and eight checks on every session start is exactly the background cost D60 avoided by keeping bootstrap.sh silent and deterministic.

cartographer service sync-timer <action>

The scheduled sync trigger for clients with no session-start hook (D140) — distinct from the server service below, with its own unit files:

cartographer service sync-timer install [--interval 30m]
cartographer service sync-timer uninstall
cartographer service sync-timer status   # exit: 0 active, 3 installed but inactive, 4 not installed
Platform Files Logs
macOS ~/Library/LaunchAgents/com.cartographer.sync.plist ~/Library/Logs/cartographer/sync.log
Linux ~/.config/systemd/user/cartographer-sync.{service,timer} journal (journalctl --user -u cartographer-sync)

install is idempotent (it overwrites and re-registers); uninstalling a timer that is not installed is a success. The timer runs cartographer sync without --auto-trust: an unattended job must not grant a trust the user never gave, while the persisted trust setting still applies. connect and status name this command once per invocation when a connected provider has no session hook — they never install it.

cartographer service <action>

Manages the server as a native user service on the machine (local mode, D73): launchd on macOS, systemd user unit on Linux. Client and server are the same binary: the client subcommands talk to the daemonized server over loopback.

cartographer service install [--config <path>] [--data <dir>] [--http <addr>]
cartographer service uninstall|start|stop|restart
cartographer service restart --wait [--config <path>]   # graceful, version-gated (D121)
cartographer service status        # exit: 0 running, 3 installed but stopped, 4 not installed

Plain restart keeps its previous behavior. restart --wait gracefully replaces the process (SIGTERM, so in-flight requests drain) and only prints success once /health proves the installed binary version is serving; --config selects the config used for that verification, and is otherwise unnecessary because the installed service definition is discoverable.

Operational details (generated paths, defaults, behavior with an existing config, automatic repair on install.sh update and Cask upgrade) in deployment.md §Example: native local service and §Upgrades, schema migration, and repo growth.

cartographer import

A mechanical import scaffold (D74 WP2), a sibling of the agentic kb-import skill (internal/skillbundle/bundled/kb-import/): unlike the other subcommands, it doesn't talk to the server, it operates directly on a local clone of the KB (--kb). It walks the .md files under --source (recursively, skipping hidden directories), maps each source directory onto a destination map (or expanded concept), fills in the frontmatter (never overwriting a field already present), and writes via kb.Open+WriteConcept. By default it leaves the working tree for the operator to review; --commit creates one final commit containing only the paths written by that import.

cartographer import --source ./obsidian-vault --kb ./kb-clone \
  --default-map notes --map people=clients/people --dry-run
cartographer import --source ./obsidian-vault --kb ./kb-clone \
  --default-map notes --map people=clients/people
cartographer import --source ./docs --kb ./kb-clone \
  --default-map notes --dir-as-concept --commit
Flag Default Description
--source (required) Source directory to import
--kb (required) Local clone of the destination KB (already initialized)
--default-map "" Default map for source directories with no --map (D77: used to be --archive)
--map (repeatable) Per-directory mapping <srcdir>=<map> (srcdir relative to --source, . for the root)
--dry-run false Prints the mapping plan (source → concept id) without writing
--commit false Makes one final commit containing only import-written paths; pre-existing dirty work is untouched
--message import: <source> -> <kb> Commit message; implies --commit
--dir-as-concept false Promotes a source directory with index.md (or README.md) into an expanded concept and keeps its satellites together

--map covers a subtree. Resolution is longest matching prefix, then --default-map, then the unmapped error (D162): --map a/b=m covers a/b, a/b/c and below, a more specific --map a/b/c=n wins for its own subtree, and matching is at segment boundaries, so a/b never covers a/bc. . is a legal source and covers everything. Before this the lookup was keyed on the exact directory, so a corpus with 58 source directories needed 58 flags — the only choices were one map for everything or one flag per directory, with nothing in between.

The matched prefix is replaced, not appended to: --map a/b=m sends a/b/c/page.md to m, not to m/c. The destination is a map (or map/expanded-concept) and the write path caps concept depth at three segments, so mirroring an arbitrarily deep source tree cannot work; preserving hierarchy is what --dir-as-concept is for. Two --map flags with the same source are an error rather than the later one silently winning, and a --map that matches nothing warns — otherwise a typo falls through to --default-map unnoticed. --dry-run names the flag behind every destination, which is how you check all of this.

import takes the KB's advisory lock and fails fast when the server holds it (D155), naming the holder and the service stop … && service start sequence: it writes into the same directory the server's sync loop manages, and the two interleaving corrupted the git index. --dry-run writes nothing and never contends for it.

The search index is not updated by this process. import writes through the KB write path but from outside the server, so the server's FTS index knows nothing about it — search returned zero results while concept_list saw everything, and the natural conclusion was that the import had failed. On completion the command rebuilds the index when a configured server is reachable, and otherwise prints the cartographer reindex instruction. It never changes the exit code: the import itself succeeded.

import rewrites markdown links. Every [text](path.md) whose target is part of the same import is rewritten to the destination's relative form, computed from the file it lands in — the base lint uses since D149, so the importer's output no longer generates findings against itself. Worth knowing before building a preprocessing step in front of it: if the caller has already rewritten links into ID space, this pass either undoes that or leaves them unmapped. The workable arrangement is to stage the corpus with its final map names and run import with identity mappings, so the rewriting is a no-op. Wiki-links [[id]] are never touched.

For every file: if it already has YAML frontmatter it's preserved, only adding missing fields; otherwise it synthesizes the minimum — title from the body's first H1 (fallback: file name), type: Note if absent (WriteConcept always requires it — a deviation from the original spec, see D74) — and in both cases it ensures status: imported, hooking into the imported_draft lint (warning) that keeps the curation backlog visible across sessions. Relative markdown links [text](path.md) are rewritten best-effort against the new layout; wiki-links [[...]] are left as-is (D72). A source directory with neither --map nor --default-map fails the command (no write) with the list of unmapped directories. Final output: counts of files imported/skipped (non-markdown)/errors — a write error on a single file does not block the rest of the batch.

With --dir-as-concept, a directory containing index.md — or README.md when no index.md exists — becomes <map>/<directory>/: the chosen file is written as that expanded concept's index.md, while its sibling markdown files become satellites below it. The dry-run labels the promotion explicitly. Without the flag, importing remains flat and a source index.md is still rejected as a reserved destination filename. --commit also commits the scaffold (_map.md, index.md, log.md) created for each new destination map; on partial write failures it commits only successful paths and reports that the batch had errors.

cartographer resolve repo:<key>|path:<name>

Resolves a path portability placeholder (D75) and prints the local path to stdout. It doesn't talk to the server: it only reads .cartographer.yaml (search_roots, paths) and, if needed, scans the filesystem (internal/repoindex) — it works even before a connect. It's the runtime fallback for an agent that encounters, in a concept's body, a placeholder missing from the "Local paths" table materialized in the instructions block (docs/sync.md §Path portability placeholders), as well as a standalone debugging tool.

cartographer resolve repo:cartographer          # short form: key = last segment of the remote
cartographer resolve repo:github.com/org/nome   # full form: host/owner/name
cartographer resolve path:design-assets         # manual paths: mapping

Exit code: 0 resolved (path on stdout), 1 not resolved (no paths: entry, no clone found under search_roots, or an ambiguous key across several distinct remotes — error message on stderr with the full form to use), 2 usage error (missing argument or not in the repo:.../path:... form).

Adding a provider

Every supported provider is one descriptor in internal/configurator/registry.go (D137): its Provider constant and wire value, display name, native MCP config file and format (FormatJSON with its server key, or FormatTOMLBlock), whether that file may be deleted once emptied (never for Claude Code — .claude.json is Claude's own shared state), whether it can carry MCP auth headers, whether its MCP tool namespace is flat across servers, the detection evidence (binary name, config directories in probe order, optional macOS app bundle), and its emitter function.

Two orders are exposed and both are user-visible: Providers() — the order EmitAll and the client subcommands iterate — and DetectionOrder(), the order cartographer agents and the TUI list agents in.

Adding a provider therefore means: one descriptor, one emitter (provider output formats genuinely differ, so that stays code), its cells in the kind × provider matrix (internal/provisioning, see sync.md §Kind × provider matrix), and — if it has a native hook mechanism — one entry in hookMechanisms. A missing matrix cell fails a completeness test; nothing else needs editing. A provider whose MCP configuration Cartographer does not own declares neither a config file nor an emitter and is skipped by connect/disconnect (ManagesMCPConfig); one that materializes outside the shared base dir declares BaseDirEnv instead (D141).

Hermes Agent

cartographer connect hermes registers Hermes for artifact delivery only. It writes no MCP configuration: Hermes' endpoint list lives in a config.yaml rendered by its Ansible role and recreated on the next playbook run, so anything written there would be lost — connect says so explicitly rather than silently doing nothing, and pointing Hermes at the server stays the operator's job. The output is scoped to match: no MCP-entry line is printed, and the closing "restart the … sessions to load the MCP tools" hint names only the providers that received one (D147). For the same reason Hermes is absent from the interactive connect form, which offers the providers whose MCP configuration connect writes.

  • $HERMES_HOME is required: it is the base dir artifacts are materialized under, recorded as base_dir in that provider's lockfile entry. Unset, connect hermes fails naming the variable instead of writing into the home directory, where the agent would never look.
  • Only skill is supported, and it is delivered to skill-inbox/<name>/cartographer/ rather than installed — adoption is the agent's own decision, via skill_manage. Nothing is ever written under $HERMES_HOME/skills/. See sync.md §Hermes.
  • The trigger is the scheduled timer (cartographer service sync-timer install): Hermes has no session hook, so nothing fires at conversation start.
  • disconnect hermes prunes the delivered inbox directories and drops the provider from .cartographer.yaml; it touches nothing else.

Files generated per provider (HTTP transport)

Provider Generated file Key
Claude Code .claude.json mcpServers (JSON)
Codex CLI .codex/config.toml managed block [mcp_servers.cartographer] (TOML, marker cartographer:mcp:*)
Kiro .kiro/settings/mcp.json mcpServers (JSON)
OpenCode opencode.json mcp (JSON)
Google Antigravity .gemini/config/mcp_config.json mcpServers (JSON)
Hermes Agent none — see below

KB-provided stdio descriptors (D116) share these same files with per-name ownership. Claude Code, Codex and Kiro receive native command, args and env fields (Kiro also keeps autoApprove: []); OpenCode uses type: "local", an ordered command array and environment with {env:VAR} references. Cartographer only preflights the local executable before writing: it never runs it, and never resolves an environment reference into its value.

Format of the generated files

Claude Code — with auth:

{
  "mcpServers": {
    "cartographer": {
      "url": "http://localhost:39273/mcp",
      "type": "http",
      "headers": { "Authorization": "Bearer ${CARTOGRAPHER_TOKENS}" }
    }
  }
}

Codex CLI — with auth (managed block in .codex/config.toml, never parsed/re-serialized: only the text between the markers is touched, via internal/blocktext):

# cartographer:mcp:begin
[mcp_servers.cartographer]
url = "http://localhost:39273/mcp"
bearer_token_env_var = "CARTOGRAPHER_TOKENS"
# cartographer:mcp:end

Codex CLI rewrites config.toml whenever it saves its own settings, re-emitting the tables in canonical form and dropping every comment — the markers with them. connect and sync reconcile this: before writing a block they remove the copies of the tables that block owns (the MCP entry, and the hook registrations of D58) left elsewhere in the file, which would otherwise be duplicate keys and stop Codex from starting, and report each removal as a warning: line. Everything else in the file — comments, ordering, unrelated tables, Codex's own [hooks.state."…"] bookkeeping — is left as it is (D99).

Recognizing a hook's own orphaned registration among those copies cannot rely only on a path fragment into the hook's materialized directory (D99's original identity): a hook whose command is a self-contained inline one-liner (e.g. a jq ... command, not a script file) never contains one. connect/sync also match on the registration's command value, decoded regardless of which of the four TOML string forms it is spelled in — Codex re-serializes a command Cartographer wrote as a basic string ("…") into a multi-line literal string ('''…''') — and compared byte-exact against the command the hook currently registers. Both identities are accepted, so an older client's path-fragment-only registrations are still adopted (D127). A hook whose command changed in the narrow window between a Codex rewrite and the next sync matches neither identity and is left duplicated — accepted as a residual, two-fault edge case; see D127.

Codex also places that same [hooks.state."…"] bookkeeping positionally after the last table it finds in the file — which, once a block has been written, is the one Cartographer owns. Before rewriting a block, connect/sync first relocate any table the block does not itself declare out of the span, verbatim, to just before the block's begin marker, so the next blocktext.Write cannot destroy it; each relocation is reported as its own warning: line (D126). Purely textual, like every other step of this reconciliation: config.toml is never parsed/re-serialized (D58).

Kiro:

{
  "mcpServers": {
    "cartographer": {
      "url": "http://localhost:39273/mcp",
      "type": "http",
      "autoApprove": []
    }
  }
}

OpenCode (schema: https://opencode.ai/config.json):

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "cartographer": {
      "type": "remote",
      "url": "http://localhost:39273/mcp",
      "enabled": true
    }
  }
}

OpenCode — with auth (OpenCode's native {env:VAR} syntax):

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "cartographer": {
      "type": "remote",
      "url": "http://localhost:39273/mcp",
      "enabled": true,
      "headers": { "Authorization": "Bearer {env:CARTOGRAPHER_TOKENS}" }
    }
  }
}

Known risk: OpenCode is SSE-first and support for custom headers on a remote MCP may require mcp-remote/mcp-auth.json; see docs/interoperability.md §Known risks.

Google Antigravity — with auth (Antigravity natively resolves ${VAR} in headers):

{
  "mcpServers": {
    "cartographer": {
      "serverUrl": "http://localhost:39273/mcp",
      "headers": {
        "Authorization": "Bearer ${CARTOGRAPHER_TOKENS}"
      }
    }
  }
}

The five formats above are generated from the same provider-neutral core, configurator.EmitServer(name, spec ServerSpec, provider) (D69): Emit(cfg, provider) is a thin wrapper around EmitServer(cfg.Name, cfg.toSpec(), provider). The same EmitServer is reused by internal/provisioning to materialize the third-party MCP servers a KB distributes (mcp/<name>.json, kind mcp) — not Cartographer's own entry, but any server, with per-name ownership in the same file (mcpServers.<name>/mcp.<name>/block [mcp_servers.<name>] marked # cartographer:mcp:<name>:begin/end). Details → docs/sync.md §MCP servers.

.cartographer.yaml

MCP descriptor approval

Unsigned third-party MCP descriptors need a separate, local approval even when trust or --auto-trust is enabled. Inspect and record the current descriptor with cartographer approve mcp <name> --kb <kb> --yes (interactive terminals default to no confirmation). Approval records the source KB, artifact name, content hash and timestamp in .cartographer.yaml; a content change requires reapproval. cartographer approve revoke mcp <name> --kb <kb> is idempotent and takes effect on the next cartographer sync, which prunes managed provider config.

Written/updated by connect in the user's home directory (~/.cartographer.yaml, machine-wide — clientconfig.TargetDir, D52): it records which server the machine is connected to and which providers are connected. One file per machine, not per project: this avoids drift with a provider connected in one repo but not another.

server_url: http://localhost:39273/mcp
server_name: cartographer  # name under which the server is registered in the MCP configs (no longer a flag: always "cartographer", override only by editing this file)
auth: false
token_env: CARTOGRAPHER_TOKENS
agents: [claude, opencode]
known_kbs: []    # mounted KB names discovered by connect/sync; empty = bare single-KB endpoint
clients:         # per-provider KB binding (D169); absent provider = every known KB
  claude:
    kbs: [homelab]
search_roots: ["~/Documents"]   # where repoindex.Scan looks for git clones for {{repo:<key>}} (D75)
search_depth: 4                 # how many levels repoindex descends from each root (D162); omitted when 0 = the default
paths: {}                       # manual name -> path mapping for {{path:<name>}} (and an override for {{repo:<key>}}, D75)

known_kbs is server-owned: connect and sync overwrite it wholesale with what /health advertises. clients is user-owned and is never written by them — it is maintained with cartographer client (below). The legacy kbs key written before D169 is still read and is migrated to known_kbs on the next write.

cartographer client

Declares which Knowledge Bases each connected provider may receive. Every subcommand works offline: it reads and writes .cartographer.yaml only and never contacts the server, so a KB name that is not currently advertised is a warning, not a failure.

Command Effect
client list one row per connected provider: resolved KBs and the origin of the answer (explicit / default (all known))
client show <provider> that provider's bound KBs, its origin, and the known KBs it is not bound to
client bind <provider> <kb>[,<kb>...] adds; creating the first binding narrows the provider from "every known KB" to only those listed, and the output says so
client unbind <provider> <kb>[,<kb>...] removes; removing the last KB leaves the provider bound to no KBs
client reset <provider> deletes the binding, returning the provider to the default

Three states, resolved only through clientconfig.Config.BoundKBs and never by testing a list for emptiness: no entry means every known KB (today's behaviour, so an upgrade never strips artifacts from an already connected client); an entry holding an empty list means no KBs; an entry holding names means those. default-deny is what declaring an entry buys, not a global mode — an operator who wants it everywhere declares a binding per provider.

bind, unbind and reset save the configuration and stop: they never trigger a sync, and print run cartographer sync to apply. bind also warns, best-effort, when the new binding creates a cross-KB collision (D171); an unreachable server makes that check skipped, never a failed command.

The binding governs the whole projection, not just artifacts: a provider's MCP entries are emitted for its bound KBs only. Two rules there are easy to get wrong and are pinned by tests — the entry shape comes from what the server mounts (a bare /mcp auto-routes only when the server mounts exactly one KB, so a client bound to one of four still needs ?kb=), while the entry set comes from the binding; and entry removal is driven by the union of every known KB, never by a provider's filtered list, or an unbound KB's entry would be orphaned forever.

cartographer status shows each provider's bound KBs and the origin of that answer, plus a per-KB breakdown of what it currently holds, read from the lockfile's recorded source.

status and sync read this file (via internal/clientconfig): without .cartographer.yaml they fail with exit 2, suggesting connect first (cartographer resolve is the exception: it works even without it, using clientconfig.Default()'s defaults).

Lockfile v2 multi-provider

.cartographer-sync.lock.json, written by connect/sync, records for each provider what has been materialized:

{
  "providers": {
    "claude": { "applied_revision": "sha256:…", "server_version": "1.4.0", "managed": [ /* ManagedFile[] */ ] },
    "opencode": { "applied_revision": "sha256:…", "server_version": "1.4.0", "managed": [ /* ManagedFile[] */ ] }
  }
}

The old v1 format ({"applied_revision", "provider", "managed"}, single provider) is automatically migrated on read (provisioning.ReadLockFile) into {"providers": {<provider>: {...}}}. See docs/sync.md §Client lockfile for the full model (drift, pruning).

TUI mode (interactive dashboard)

Running cartographer with no arguments in a terminal opens an interactive dashboard (cmd/cartographer/tui.go, bubbletea): a server block, then one card per provider with an explicit status (connected / not connected / not installed) and indented details below, laid out on a two-column grid:

server     http://localhost:39273/mcp  in-sync · ready
version    client v0.10.0 · server v0.10.0
service    local: installed · loaded
KBs        kb-uno (2 bound) · kb-due (1) · kb-tre (0) · kb-quattro (0)

> Claude Code    connected
      binary      /opt/homebrew/bin/claude
      mcp-config  in-sync
      kbs         kb-uno, kb-due  (explicit)
      artifacts   in-sync
      kinds       skill 5/5 · agent 4/4 · hook 2/2 · instructions 1/1

binary and kbs are local data and are in the first frame; artifacts and kinds are fetched asynchronously against the configured server.

  • kbs is the provider's own binding (D169): the declared names with (explicit), all known (default) when nothing was declared, and none (explicit) for a binding deliberately emptied. A list too long for the row is truncated with a counter (kb-uno, kb-due +2) and never wrapped.
  • kinds is the per-kind breakdown formatKindStatus produces, the same one cartographer status prints — both read it off the shared snapshot. It says unknown before the first fetch resolves and after one that failed: a breakdown computed against a manifest that was never fetched is not a clean bill of health. no artifacts is the distinct case of a manifest that was read and holds nothing.

Main keys:

Key Action
enter / s Connect (if not connected) or resync the selected provider
S Sync every connected provider, one at a time under a single client lock (D172)
d Disconnect (if connected) — opens an inline y/n confirmation naming its KBs
r Refresh status
q / Esc Quit

d on a connected agent opens a confirmation screen (y confirms, any other key, including n/Esc, cancels and returns to the list); on a non-connected agent it's a no-op. The TUI is a subset of the CLI: it uses the same doConnect/doDisconnect logic as cartographer connect/disconnect, but doesn't expose --dry-run/--auto-trust. Outside a TTY, cartographer with no arguments prints the usage (like cartographer help).

Non-destructive merge

If a config file already exists, connect reads the existing content, adds or updates only the server's key (server_name, default cartographer) and rewrites the file. Other configuration present in the file (other MCP servers, other keys) is left untouched. disconnect performs the inverse operation (configurator.Remove): removes only the server's key from the mcpServers/mcp map and rewrites the file, leaving everything else intact (other MCP servers, other top-level keys).

Installation

brew install beppetemp/tap/cartographer     # preferred path on macOS (cask from the tap)
# or:
curl -fsSL https://raw.githubusercontent.com/BeppeTemp/cartographer/main/install.sh | sh

install.sh downloads the latest binary from the GitHub Release for the current platform (darwin/linux × amd64/arm64), verifies the checksum if sha256sums.txt is present in the release, and installs it into /usr/local/bin (or ~/.local/bin if not writable). Also supports update and uninstall as the first argument. See docs/deployment.md §CI/CD and client installation.