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; the help of root, paths, kb, client and workspace (help, -h or --help as the only argument) and of service (-h or --help only) writes to stdout and exits 0, with no client config needed; -h/--help anywhere in the arguments of agents, status, approve, config, mcp, update, client, workspace and service sync-timer does the same, and no subcommand answers help with an Error: line or exit 2; no argument outside a terminal prints the usage on stdout and exits 0, and an unknown subcommand fails on stderr with exit 2. 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. For each connected provider whose managed instructions block is over 16 KiB (about 4k tokens, bytes/4, approximate; D1182) status prints [<provider>] instructions block <size> KiB (~<n>k tokens, approximate) over 16 KiB: <the three largest KBs> and sync prints the same line after its report; JSON carries it additively as instructions_blocks (path, bytes, approx tokens, threshold, over, largest KBs) on each provider, schema unchanged. It informs and changes neither the state nor the exit code, and a block that was not written is not measured. service status retains 0 running, 3 stopped (on every platform, a Windows task left Disabled or Ready by stop included) and 4 not installed.

cartographer hook write-findings [--channel stderr|context|copilot] (internal)

Not in the usage text. The logic of the client-generated cartographer-write-findings hook (D353, docs/sync.md §Layer 1): reads a Claude Code, Codex, Copilot CLI or OpenCode 2.x PostToolUse payload on stdin and looks in tool_response (the result object, a content-block array, or JSON in a text block, or a string of several JSON values as OpenCode joins them; supersede appends findings: as plain text) for a findings array or results[].findings (concept_batch). With at least one finding it writes a fixed English message (a header with the count, up to 10 <check> <path>: <message> lines, +N more, and the instruction to fix them: nothing accepts a finding, D907; a finding wrong for a page that is right as written is a check defect, reported in the session log and never closed by removing evidence, as kb-doctor step 6 says). --channel stderr (default, Claude Code) writes it to stderr and exits 2; --channel context (Codex, D361) prints {"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"<message>"}} on stdout and exits 0, never 2; --channel copilot (Copilot CLI, D676) prints {"additionalContext":"<message>"} on stdout and exits 0, reading Copilot's camelCase payload (toolName, toolResult.textResultForLlm, the tool named <server>-<tool>). Anything else (no findings, an unknown shape, a parse error, a panic, an unknown flag or channel) is silent exit 0; with the stderr channel stdout is always empty. A wrong subcommand name exits 2. tool_name may be mcp__<server>__<tool>, <server>_<tool> (OpenCode, D362), <server>-<tool> (Copilot) or the bare tool name.

cartographer update check|notice

Tells an agent, and its user, that a newer release exists (D254). The source is the GitHub release list (/releases?per_page=10, which includes the pre-releases every 0.x tag is; the newest one that is not a draft and has a version tag without a pre-release suffix wins: a release candidate v<next>-rc.<YYYYMMDD> is never offered, whatever its GitHub flag, D1192), cached for 24 h in <user cache dir>/cartographer/update-check.json. A lookup times out after 3 s and any failure means "unknown": nothing is printed. A dev build never checks. A newer release is announced only once the channel the binary came from can install it (D1351): homebrew when the tap's cask names that version, install.sh / install.ps1 when the release carries this platform's binary and sha256sums.txt; go-install and unknown ask for nothing more. Until then nothing is printed and no release is cached; the not-yet-installable tag is remembered for an hour (update-check.json.pending), so session starts do not re-ask, while update check always asks and prints latest vX (not yet installable through <channel>). A current binary makes no extra request. CARTOGRAPHER_NO_UPDATE_CHECK=1 or update_check: false (below) turns it off; CARTOGRAPHER_UPDATE_API_URL and CARTOGRAPHER_UPDATE_CASK_URL exist for tests only.

Command Effect
update check [--output table\|json] Refreshes regardless of the cache age, prints current, latest, channel and command. Always exit 0 (2 on usage errors). JSON: {current, latest, available, kind, channel, command}, additive
update notice What the session-start hook runs. Prints nothing unless an update exists, then one paragraph for the agent: the versions, the upgrade command, and the instruction to tell the user once and run it only on consent. Always exit 0

The command depends on the channel the running binary came from: homebrew (Caskroom, or the /opt/homebrew/bin//usr/local/bin symlink into it) → brew upgrade --cask beppetemp/tap/cartographer; install.sh (/usr/local/bin or ~/.local/bin as a plain file, or CARTOGRAPHER_INSTALL_DIR) → curl … install.sh | sh -s -- update; install.ps1 (%LOCALAPPDATA%\Cartographer\bin) → the install.ps1 … update one-liner; go-install (GOBIN/GOPATH/bin) → go install …@<latest>. container (/.dockerenv) and unknown get no command: the notice names the image tag or the releases page.

The client never installs a release by itself (D906): there is no auto-patch, only this notice and the command above. A file that still says update.policy: auto-patch loads with a warning, and the realignment turns it into update_check: true.

cartographer setup

The first-run path in one command (D253): the native service, the first KB and the agent clients, then a health check. It adds no behaviour of its own — each step is the existing command (service install/start, kb create/kb clone with --restart, connect --no-input) — and it decides every step before running any:

Step Decided from Already done when
server service status installed and running (installed but stopped → service start)
KB --remote, --no-remote, or the interview; git ls-remote <url> a KB in the data dir has that origin (trailing / and .git ignored)
auth check an unauthenticated GET /mcp on the service (skipped when the client config already sends a token) answers anything but 401
agents --agents, else every detected client never skipped: connect is idempotent and re-syncs
verify /health through service status —
  • The remote decides create vs clone. An empty ls-remote answer means an empty repository: kb create <name> --remote <url>; any ref means content: kb clone <url> <name> — which, for a repository holding only a forge's initial commit (README/LICENSE/.gitignore), answers with kb create <name> --remote <url> instead, since that adopts it (D710). A README.md of that commit is replaced by a short generated one (a new commit on top, the forge's commit stays) only when it is a known forge boilerplate (the title and at most a description line of GitHub/Gitea/Forgejo, GitLab's "Getting started" template); any other README is kept and a hint names --readme generate, which replaces it whatever it holds, while --readme keep never replaces it (D1071). kb create --branch X (with --remote) makes X the KB's branch: it writes branch: X in settings.yaml, pushes the default branch carrying it, then creates and pushes X and leaves the clone on it; kb clone --branch X is per server instead: it checks X out and adds a kbs[] entry with branch: X to the server config (D1093). The name is --name, else the repository name. The same probe proves the remote is reachable with this machine's credentials, under the non-interactive git environment of kb clone (no prompts, SSH batch mode, host keys never auto-accepted — D173); a failure exits 2 before anything is written, with the remedy for the recognised git errors.
  • Workspace. --workspace <repo> is passed through to connect --workspace (D193): the KBs are confined to that repository instead of every directory.
  • KB binding (D190). --kb is passed through. Without it: one KB binds itself; agents that already carry a binding keep it; a KB this run adds next to existing ones is bound alone (the narrowest choice); otherwise, with two or more KBs and nothing to go on, an interactive run asks with the same picker as connect and a non-interactive one exits 2 naming --kb.
  • Refusals, all before any change: no git on PATH; a client already pointed at a non-loopback server (setup provisions a local one — connect is the command for a remote server); no remote and no mounted KB in a non-interactive run; no agent detected and none named.
  • A service that demands a token stops setup before connect (D268). The agents setup configures reach the local server with no token, so a service answering 401 would fail them one by one (sync_pull: … unauthorized (401)). That happens when a CARTOGRAPHER_TOKENS exported for another server reaches the service's environment under a server.yaml without auth.mode under a server.yaml that does not pin it; a config service install generates pins mode: "off" for a loopback address (D268). The error names the variable (saying whether it is set in the current shell, and CARTOGRAPHER_AUTH if that forces auth on), the config file to add auth: {mode: "off"} to, and service restart; exit 1, and the rerun resumes at the agents.
  • Interactive (a TTY, no --no-input): asks for the remote when none is mounted — blank means a local-only KB, confirmed explicitly (D134) — then which of the detected agents to connect, prints the plan and asks Proceed? [Y/n] (skipped by --yes). --dry-run prints the plan and exits 0.
  • Exit codes: 0 set up; 1 a step failed or the operator cancelled; 2 usage, preflight or choice error (nothing changed). A failed step prints Setup stopped at …; a rerun skips what is done. On Linux a freshly installed user unit without lingering gets the loginctl enable-linger hint. The run ends on the Atlas URL and on restarting the agent sessions.
cartographer setup                                                  # interview, plan, confirm
cartographer setup --remote git@github.com:me/wiki.git --agents claude --yes --no-input
cartographer setup --no-remote --name trial --agents codex --yes    # local-only KB
cartographer setup --remote <url> --dry-run                          # the plan, nothing changed

The install scripts end a first install on Next: cartographer setup. The Homebrew cask cannot tell a first install from an upgrade, and its caveat names both: First install: cartographer setup, and for an upgrade that the next cartographer sync switches the service to the new binary or cartographer upgrade-repair does it now (D280). The dashboard shows the same hint on its next line while a loopback server has no service behind it or answers with no KB mounted.

cartographer agents

Lists the supported providers, whether they are installed on the machine and whether they are connected (recorded in the client state file .cartographer-state.json, beside the machine-wide ~/.cartographer.yaml).

internal/agents.Detect probes, in this order, and stops at the first match; the heuristic that matched is reported in the DETECTION column, and as detected_by in JSON (D224):

  1. binary — any of the provider's binaries in PATH (Kiro ships as kiro from the IDE and kiro-cli standalone). On Windows exec.LookPath honours PATHEXT, so this is where a client installed normally is found;
  2. config-dir — a known configuration directory under the home directory;
  3. env-config-dir — a configuration directory anchored at an environment variable, for a location no home-relative path can express — today %APPDATA%\opencode. An entry whose variable is unset is skipped, so declaring one costs nothing on a platform that does not define it;
  4. app-dir — an application-installation directory declared for this GOOS. Only a confirmed location is declared: /Applications/Kiro.app and /Applications/Antigravity.app on darwin, nothing on Windows, where the CLI on PATH is the detection that matters and an unverified install path would only invent evidence (D216).
cartographer agents
PROVIDER   INSTALLED  CONNECTED  DETECTION   EVIDENCE
claude     yes        no         config-dir  /Users/u/.claude
codex      yes        yes        binary      /opt/homebrew/bin/codex
kiro       no         no         -           -

The distinction the column carries is the one INSTALLED cannot: binary is a client that is on the machine now, while config-dir, env-config-dir and app-dir are directories, which a client that was removed leaves behind. INSTALLED — and therefore which providers a bare connect or connect all targets — is unchanged: it is still true for any match (#305).

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. A transport failure (dial, DNS, timeout) defers as "server unreachable"; any other answer that is not a rejection (a 404 or 5xx from /health) also defers, worded "the server did not serve the skills", and probe messages say "the server answered but the probe failed" for it; a server that answers 401/403 fails connect (exit 2) naming the rejected token and where it came from, and a signature error fails it naming the KB, in both cases without saving .cartographer.yaml (#1299). 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. connect, sync and upgrade-repair also remove the scheduled headless doctor job an earlier release installed, printing one line when there was one: Cartographer never starts an agent by itself (D704), except a worker the operator has configured and deployed (D1258, the worker). sync matters after a Homebrew upgrade, which runs neither of the others; doctor run|schedule|unschedule|status fail as unknown arguments.

Token file (D699). With auth: true and a token: ${NAME} reference (the default is ${CARTOGRAPHER_TOKENS}), connect (so reconnect and setup) also copies the token to a .cartographer-token file next to .cartographer.yaml (0600, atomic, never through a symlink), because the sync timer runs without a login shell's exports. The environment variable stays the source whenever it is set; rotate it and run cartographer reconnect to refresh the copy. A file readable by group or others is refused (chmod 600 fixes it). disconnect removes it with the last provider; reconnect keeps it. The file is what an unattended run reads when the variable is absent. A literal token: <value> needs no copy and no variable: .cartographer.yaml is then written with mode 0600 and connect writes no token file; the agents' MCP entries can only name a variable, so they reference $CARTOGRAPHER_TOKENS and never carry the literal (nor does any provider file). A literal token therefore reaches the agents only if $CARTOGRAPHER_TOKENS is exported in the environment that starts them, and connect and sync print a warning saying so; for agents that should work without it, use a ${NAME} reference. A token is never put on a command line (--token-env takes a variable name, no flag takes a value) and never printed.

Multi-KB servers (D92). connect reads GET /health, with the token, before emitting MCP configuration (a scoped token sees only the KBs it reads, D1096). 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-state.json (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.

Kiro hooks (D300). KB hooks and the bootstrap hook reach Kiro as entries of ~/.kiro/hooks/cartographer.json, a standalone hook file Cartographer owns whole, with their files in ~/.kiro/hooks/cartographer/<name>/. They fire only in kiro-cli chat --v3 --tui, so status reports hook n/m (fires in kiro-cli chat --v3 --tui only). connect, status and doctor keep advising the scheduled timer for Kiro, naming that limit rather than calling it hookless; status stops repeating it once connect or reconnect has shown it (D320, sync.md §Detection and triggers). Nothing is written into a Kiro agent config and chat.defaultAgent is never set (interoperability.md §Kiro hooks).

Read-only profile (D1184). connect <provider> --access read-min writes that provider's entry as <server>/mcp/routed[?kbs=…]&access=read-min&provisioned=1: the connection is read-only and lists a minimal tool set instead of the full one, for an agent that only reads (control-plane.md §MCP API). The value is saved in clients.<provider>.access for the providers of that run, which is not a KB binding (the provider keeps receiving the KBs it received), and a later bare connect or sync keeps it; clearing it is editing the key out of .cartographer.yaml, then sync. Two refusals name the fix: a server with no routed endpoint (or that did not answer), and a provider with a workspace binding, whose bridge does not carry the profile (workspace unbind it first). A provider that has the profile never gets the bridge: workspace bind refuses it, and bindings it gains otherwise (derived ones) are ignored, with a warning from sync, while it keeps its HTTP entry. The interactive form does not offer it.

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. 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; a path never bound for that provider is an error (exit 1). workspace bind refuses a provider that is not connected (<provider> is not connected, exit 2, as client bind does), dedupes --kb and, like client bind, refuses a KB the server does not mount when the server answers with its list (exit 2); with the server unreachable it warns and binds. For Codex, workspace bind codex <path> --kb <name>… --trust also records the folder as trusted in ~/.codex/config.toml, so the projection is not born inactive (one explicit, narrow write; without the flag bind prints the line to add; unbind and a derived binding never touch it; refusal cases and the exact rules in sync.md §Workspace scope, D1074). A binding also scopes the tools in every client, with no file written into the workspace: sync writes the provider's global MCP entry as a local stdio bridge, cartographer mcp --provider <p> [--dir <folder>] (the folder defaults to the working directory), which serves only the KBs of the folder the session runs in (deepest binding wins; an unbound folder, $HOME included, gets the provider's default set read-only and no KB artifact (D1018), or read-write when unbound_access says write (#1356, still no artifact); --no-kb means no KB, never every KB). It is a scope filter, not a security boundary: hard isolation needs a per-workspace token (D44/D45). Artifacts stay opt-in per scope (above). workspace resolve [<path>] [--provider <p>] shows which binding wins for a folder (for one provider, or each), says when it is derived from a KB's paths.yaml (D1019), and, for one that no binding covers, prints access: read-only (default), access: read-only (unbound_access) or access: read-write (unbound_access). workspace list shows the bindings, with [gone] next to a directory that is not there any more and [derived: paths.yaml of kb-a] next to one a KB's declared repository implies, then the declared repositories with no local clone (D737, sync.md §Workspace scope). Limits: the bridge opens no server-push stream; if cartographer is not on PATH when sync runs, the provider keeps its HTTP entry with a warning; a client with roots in an unbound folder gets a handshake naming no KB.

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.

One routed entry (D187, D288). When /health reports routed_path (every server since D288), the KB is no longer part of the entry name: the client writes one entry per provider, <server_name>, pointed at /mcp/routed. The provider's binding travels in the URL: an explicit binding writes ?kbs=<bound KBs, sorted, comma-joined>, which the server turns into that connection's KB set (two or more KBs: kb is a required enum in every schema; exactly one: no kb argument at all); the default binding, every KB the server mounts, writes no kbs, so a KB added later reaches the provider without a rewrite. Every routed entry ends with provisioned=1 (/mcp/routed?provisioned=1, /mcp/routed?kbs=kb-a,work-kb&provisioned=1; D1097): the client has the managed block, so the server sends it no operating guide in initialize. The marker is always written, a server that predates it ignores it, and mcpbridge adds it to the URL it forwards to. One sync adds it to an entry written before. A bound name the server no longer mounts is left out of the URL (the server refuses an unknown name), and a provider bound only to vanished KBs gets no entry, the same declaration as an explicit empty binding. kbs only narrows, never widens: the per-KB authorization still runs at the target, and with auth: false the binding is advisory, since the client can edit its own URL. The generated instructions block names the bare tools and says to pass kb: "<name>" where a tool takes a kb argument, which is true for both shapes. The topology is persisted in .cartographer-state.json (server_mount_mode, server_routed_path) so doctor and status can derive the expected entries offline.

sync heals a topology change; no reconnect is needed. A client whose state predates D288 holds per-KB entries (<server_name>-<kb> → /mcp?kb=<kb>). The next cartographer sync removes every name the client may have owned (the bare name and one per known KB, including unbound ones) and writes the single routed entry, so one sync leaves exactly one entry per provider and no orphan. cartographer status reports mount mode changed: … and names cartographer sync until then. Against a server that does not report routed_path (pre-D288) the client keeps the historical per-KB shape, with the two warnings below.

Kiro and flat tool namespaces (D102), pre-D288 servers only. Kiro's MCP tool namespace is flat across servers: writing 2+ MCP entries for kiro against a server that does not route leaves only one KB's tools reachable in a Kiro session unless that server mounts the others with a tool_prefix. connect/sync warn on stderr in that case and read each KB's effective tool_prefix from /health. Against a D288 server the warning never fires: one entry cannot collide with itself.

Antigravity and the 64-character tool identifier (D201), pre-D288 servers only. 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. A routed entry carries unprefixed tools and is checked with no prefix; without /health facts the check stays silent.

Prefix discovery (D120), pre-D288 servers only. 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 one re-derived locally from the KB name. A D288 server advertises none, so the tool names are bare.

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 --dry-run

The provider (and, for kb subcommands, the KB name) may be written before, between or after the flags (connect claude --kb all, connect --kb all claude); a second positional is a usage error naming it (exit 2), never ignored. A --server-url ending in /mcp/<segment> that fails /health with 404 is answered with a hint that the server URL is the one ending in /mcp.

Flag Default Description
(positional) all claude | opencode | codex | kiro | antigravity | crush | copilot | 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://127.0.0.1:39273/mcp Cartographer server URL
--auth false Enables the Bearer header in generated configs
--token-env CARTOGRAPHER_TOKENS Name of the env var holding the Bearer token; written as token: ${NAME}
--dry-run false Prints what would be written, in the conditional (would write, would connect), and writes nothing (D147)
--allow-insecure-http false Accepts a plain http:// server URL that is not loopback and records allow_insecure_http: true; without it connect refuses such a URL (§Transport security, D1023)
--pin-key (repeatable) Pins KB=PUBLIC_KEY for Ed25519-verified provisioning artifacts; existing pins are preserved
--unpin-key (repeatable) Removes the pinned keys of a KB from .cartographer.yaml; applied before --pin-key (so both replace a pin), and a KB with no pin is an error. A pin that does not verify the server's artifacts fails connect with a signature error (exit 2, .cartographer.yaml untouched), not a "server unreachable" deferral
--no-input false Never opens the interactive form, even in a TTY
--workspace (unset) Projects the selected KBs into this repository instead of the global catalogue (D193)
--auto-trust false Accepted and ignored, with a warning (D906)

Transport security (D1023)

The bearer token and the responses must not cross the network in clear. Every client request goes through client.CheckServerURL: https:// is always accepted; http:// only when the host is loopback (localhost, or an IP for which net.ParseIP(host).IsLoopback() holds: 127.0.0.0/8, ::1, IPv4-mapped ::ffff:127.0.0.1; a name such as 127.0.0.1.example.com is not); any other scheme is refused. The check is in the client transport, so connect, sync, status, doctor, kb repair, kb reauthor, reindex, the usage sync and the stdio bridge all obey it, and connect and sync fail before writing anything.

The explicit opt-in is the allow_insecure_http: true key, set by connect --allow-insecure-http (re-run connect with the same flags; it only adds the key), or CARTOGRAPHER_ALLOW_INSECURE_HTTP=1 for one run. A machine already connected to a plain-http remote is refused at its next sync with an error that names both fixes. Nothing upgrades http to https by itself.

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 not entered here, the form saves only the name; with Auth off the field is rendered secondary and the hint says it is ignored). In the standalone connect form, one checkbox per provider is 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://127.0.0.1:39273/mcp — the loopback literal the local service listens on, never localhost, which Windows resolves to ::1 first, where nothing listens (D231). A .cartographer.yaml still carrying the old default http://localhost:39273/... is read back on 127.0.0.1 (path kept), so the next connect or sync rewrites every client's MCP entry; any other URL is left as written. 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).

Placeholder step (TTY, D262). After the first materialization, when some {{repo:…}}/{{path:…}} key the bound KBs cite did not resolve on this machine, a TTY connect (no --no-input, no --dry-run) opens a second step (pathsform.go, a separate step like the KB choice because the keys are only known after the pull): one row per unresolved key, with its KBs, the description its KB's paths.yaml declares (D263), the reason, a text input for the local path — pre-filled with the declared default when there is one (it did not resolve, so it does not exist here yet) — and an empty value to skip it. A key whose declared default exists is resolved without being asked. Answers are written into paths: in .cartographer.yaml and the instructions block is materialized once more so its "Local paths" table carries them. Outside a TTY nothing is asked: the apply summary prints one summary line for the unresolved keys, with detail and the cartographer paths set/paths ignore fixes only for keys new since the last sync (D282), and the exit status is unchanged — an unresolved key never blocks a connect.

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 the server does not answer at all (never when /health replied, e.g. a 404 on a /mcp/<segment> URL); 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 the client state — 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; an emptied mcpServers/mcp map is dropped, and the file itself is deleted only when it is left an empty shell and the provider's registry entry sets DeletableWhenEmpty: .claude.json and the Claude Code hook settings files go only when Cartographer created them (recorded in .cartographer-created.json in the base dir, itself deleted with its last entry) and removing our entries leaves {}; a file that existed before, even as {}, or that holds another key, is kept), prunes the managed artifacts registered for that provider in the lockfile (provisioning.PruneManaged — only managed files, never untracked ones) and what its workspace projections wrote in each bound repository (the files the lockfile records for them, exactly as workspace unbind + sync would; what was there before stays, a skill, agent or hook that no longer hashes to what the sync wrote stays and is reported as kept <path>: changed since sync, a lockfile path that is not inside the repository is never followed, and a file another provider's projection of the same repository still records stays too), prints which repositories it cleaned, then removes the provider from the lockfile and from the client state, its workspace bindings and projection scope included (#1324). 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 an empty agent list in the state file, preserving server_url/server_name/auth/token/clients as defaults for the next connect (a disconnect→connect restarts from the previous server, not from http://127.0.0.1:39273/mcp). The client state files also stay after a disconnect: .cartographer.yaml, .cartographer-state.json, .cartographer-client.lock (in the config directory), .cartographer-created.json (in the home or workspace, only while a Claude Code file it created is kept because it holds other keys) and workspace-derived.json (in the cache directory, ~/.config/cartographer/). .cartographer-client.lock is an empty lock file, not state: the first sync or connect creates it and a dry run does not announce it. To remove every trace, delete them by hand once the last provider is disconnected.

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 | antigravity | crush | copilot | 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, ...). Exit 1 when a provider was kept connected because its config or managed files sit behind a symlink (§Symlinked config files and directories are refused); the other providers are still disconnected.

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; an unsigned KB artifact that is not an MCP descriptor, a hook or an executable file is trusted: it is installed, D906). Artifacts in needs_approval/approval_stale get their own cartographer approve <kind> <name> --kb <kb> hint. A pending hook also names the file the sync registers it in, resolved by the function the sync prints with (a derived workspace binding: .claude/settings.local.json, #1300). 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). A newer release known to the update cache adds update available: vX (installed vY) — <command> (JSON update: {latest, kind, channel, command}, present only then), and a remote server that reports one in /health adds server update available: vX — the server operator's job, so no command. Both read local state only (the cache is refreshed by the session hook and update check) and neither changes the exit code (D254). Placeholder keys the last sync could not resolve and the operator did not paths ignore (read from the lockfile, no network) add N placeholder(s) unresolved — run `cartographer paths` to the table and an additive unresolved_placeholders: [{key, kbs, reason}] to the JSON, present only when non-empty; neither changes the state or the exit code — an unresolved key is left verbatim, not a failed sync (D262). 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. A server that answers /health but is still bootstrapping its KBs is not unreachable: the snapshot says reachable: true, ready: false and error: {code: "starting", message: "the server is starting and loading its KBs; retry shortly"} (state unavailable, exit 2), and the table prints that message instead of the endpoint advice (#1305). A managed artifact that diverged on disk is listed as diverged on disk (<reason>); for an unrunnable hook — a Claude Code hook whose registered command cannot run, such as a backslash path on Windows or a file that is not there — the next line says which command and why (JSON detail), and sync re-registers it (D267). A provider's stdio bridge entry (D737) is listed the same way, as mcp/<server>, when its command is not the cartographer that sync would find on PATH now (the binary was moved or removed since connect, as after trying a release candidate from $HOME/.local/bin): status exits 1 and sync or upgrade-repair rewrites it. An entry that is not the bridge (an HTTP entry, or one written by hand under the same name with other arguments) is not judged.

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 (with · vX available in the drift colour when a newer release is cached, D254), 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>]... [--dry-run] [--no-heal] [--log-file <path>] [--report-to <url>]
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 the plan without writing: every artifact and MCP entry, the .cartographer.yaml rewrite (only when its bytes would change) and the creation or update of workspace-derived.json; MCP entries are listed for removal only when they exist in the config
--no-heal false Reports managed artifacts that diverged on disk instead of restoring them from the server (D139)
--log-file (none) Appends this run's output to that file instead of stdout/stderr (created if absent, never rotated); the Windows sync task uses it, where nothing else records a failing background run (D217)
--auto-trust false Accepted and ignored, with a warning (D906): session-start hooks written by older versions still pass it
--report-to (none) Only sends this machine's usage and client-settings reports (D326, D818) to that MCP endpoint, and changes nothing: the config is read, never saved, nothing is provisioned, the configured server is not contacted. It is how make dev DATA=<dir> shows the real client on a live instance (D1044). The configured token goes with the reports, so the URL must name a server on this machine (localhost, 127.0.0.1, ::1): any other host is refused with exit 2

For Claude Code the cartographer MCP entries are written to $HOME/.claude.json and to <dir>/.claude.json for every clients.claude.config_dirs (D755), and removed from all of them. A declared dir that is missing, relative or holds a symlinked .claude.json is skipped with a warning, never created. A CLAUDE_CONFIG_DIR set at sync time but not declared is a warning naming the key; nothing is written there. Skills, agents, hooks, CLAUDE.md, the bootstrap hook and KB-provided mcp artifacts stay under $HOME.

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.

A KB whose sync_pull fails does not stop the others. sync applies every provider whose bound KBs all answered, leaves each provider bound to a failed KB exactly as it was (its MCP entries, artifacts and lockfile entry, as if --client had left it out), then exits 2 with one error listing each failed KB and the providers it skipped. When every targeted provider needs a failed KB, nothing is written and the error says so. upgrade-repair gets the same behaviour through the same code, and reports the provider sync as pending (D257).

Before anything else — before the client-state lock and before the first network call, --dry-run included — sync checks the environment it needs: when .cartographer.yaml has auth: true with a token: ${NAME} reference, that variable or the token file (D699); a literal token needs nothing here. Missing ones are reported in one error naming each, so an unattended run (session-start hook, sync timer, CI — none of which inherit a login shell's exports) diagnoses its misconfiguration before the first network call (D222). The check is read-only and never contacts the server: it verifies a token exists, not that the server accepts it — a token that is set but wrong is still a 401 from sync_pull. When nothing is missing it prints nothing.

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 — once per invocation, whatever the provider count — and then syncs normally (D142). The line states the fact and that this run re-applies the current manifest; it carries no imperative, because the run it precedes is the repair (D220). status and doctor report the same fact and do name reconnect: they only observe. 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 | antigravity | crush | copilot | 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 (the token reference), 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 [--output table|json] [--provider claude]
Flag Default Description
--output table json emits the report as JSON (cartographer.doctor/v1) instead of text, like agents, status and update check
--json false Deprecated alias of --output json, kept working
--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 — except --repair-hashes below, the one flag that rewrites the lockfile. 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, unrunnable (a Claude Code hook whose registered command cannot run, with the reason — D267); 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; for OpenCode, the plugin shape written against the installed major (D359) — a 1.x plugin on OpenCode 2.x is an error, an unsupported major a warning, an unreadable version a warning (the shape was guessed, D719)
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); a timer the operator uninstalled on purpose is info (D325)
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, or anything else that is not a plain directory — provisioning refuses to write through one, so the artifacts it would hold are not installed (D148, widened in D216)
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
legacy_steering_pattern the operator's own steering text — the provider's instructions file outside the managed block, plus, for Kiro, the other *.md files beside ~/.kiro/steering/cartographer.md — names a pre-D288 prefixed tool (<prefix>__<tool>, where <tool> is a real Cartographer tool and <prefix> neither is mcp nor contains __, so Claude Code's mcp__<server>__<tool> names are not flagged), which sync never rewrites there. Warning severity; the fix names the unprefixed tool (D320)
update_available a newer release is in the update cache (D254). Info severity; the fix is the channel's upgrade command. Cache only, so doctor stays offline
workspace each workspace binding of a provider in workspace scope: the directory is there, its remote is the one bound, Codex trusts it; and, in a git repository, the untracked files under the provider's directories (hook companions such as .codex/hooks.json and the OpenCode plugins included) that neither the info/exclude block nor an ignore file covers (#1199); for a derived Claude Code binding, a tracked .claude/settings.json holding Cartographer hook entries, which a derived binding registers in .claude/settings.local.json (D1198). Warning severity; sync rewrites the block, and a file of the user's is committed or ignored
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, under the client-state lock (D157). It exists because the alternative for that narrow gap, reconnect, prunes and rewrites every managed artifact on every client. 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 running every check on every session start is exactly the background cost D60 avoided by keeping the bootstrap script silent and deterministic.

cartographer kb repair <kb> [--apply] [--json | --revert <sha> [--reason <text>]]

The unattended half of KB upkeep (D299), schedulable by the operator (cron, launchd, a CI job); nothing installs a schedule. It calls kb_status, then kb_repair with dry_run: true for every check with a mechanical fix, and prints per check the planned count and up to 5 examples. With --apply it re-runs only the checks whose fix is safe to run unattended (D323, D355, D907: lint.AutoRepairChecks, [auto] in the report) with dry_run: false; any other check is never applied by --apply. No setting narrows the list. The server applies the same checks by itself every day, and each write already repairs the concepts it wrote (D349, always since D907), so --apply usually finds little. It ends with the judgement work left — the kb_review counts by kind — which only a cartographer-kb-doctor session with the operator handles. It writes no kb-doctor log entry: a mechanical pass is not a doctor session, and the entry would silence the server's doctor proposal while the judgement work is undone (each applied check still leaves kb_repair's own entry).

--revert <sha> replaces the repair: it calls repair_revert, which undoes one kb_repair or auto-repair commit (its SHA is in the KB's git log, reason "auto-repair (background)") as a new commit through the server's ordinary write path, and refuses any other commit.

The commits of --apply and --revert are authored as whoever runs the command (D904), by the bridge's rule: the KB's identities entry, else identities.default, else what git resolves in the folder the command runs in, and only inside a work tree. An empty entry sends no author, an invalid one sends none with a warning; without an author the server authors the commit as the token. A scheduled run outside any repository is therefore authored as its token unless identities names someone.

Exit codes: 0 nothing left to do, 3 only judgement work or checks that are not safe to run unattended remain, 2 error. --json prints the report as an object (checks, review_total, review_by_kind, remaining). Not to be confused with cartographer doctor, which checks the client's provisioning.

cartographer kb reauthor <kb> [--apply] [--json]

Recovery for a push a remote refused because of who authored or committed its commits (a membership push rule, a private-email rule; D1063). It re-authors, under the identity the server file configures for the KB (git.identity / git.identities, the rule serve applies at mount), the commits that exist only in the local clone, then pushes. Without an identity configured it refuses: rewriting with the fallback identity would reproduce the problem. The sync alone already re-authors a commit made by exactly the server identity (D950); this command is for what it leaves alone, a commit authored by a token (<token id> <cartographer@host>).

Like kb unmount it is offline and local (D177: --data, --config, --local), because it needs the server config and the clone on this machine and operator authority, not a token. It takes the KB's advisory process lock, so a running service does not have to be stopped, fetches origin with the KB's credentials, and moves the branch with a compare-and-swap update-ref, so a write racing it makes the rewrite fail instead of being lost. The working tree and index are never touched.

Without --apply it is a preview: every commit ahead of the upstream with its author, committer and subject, marked * when it would change; nothing is written. --json prints {kb, branch, upstream, identity, commits:[{sha, author, committer, subject, rewrite}], apply, rewritten, pushed, error?} (after a failed push, rewritten says the rewrite worked, pushed is false and error says why). With --apply, commits that already carry the identity are pushed without being rewritten. --apply replaces author and committer, keeping message (so the Cartographer-Author, Cartographer-Token and Cartographer-Host trailers stay as the record of who really wrote), tree and dates, logs the old → new count on stderr (the branch reflog records the command), and pushes through the normal path. A running service recomputes its sync status at its next sync or write.

It refuses, with exit 2 and a one-line reason, when: there is no identity configured; the directory is not a git clone with an origin; HEAD is detached; a rebase or merge is in progress; the fetch fails (what is only local must be proven against the remote); the branch has no upstream; it has diverged (behind the upstream); the commits ahead hold a merge or a root commit; or any of them is already reachable from another remote ref (published history is never rewritten). Exit 0 is done or nothing to re-author.

cartographer service sync-timer <action>

The scheduled sync trigger (D140) for clients with no session-start hook, or one that fires only in some sessions (Kiro, D300). With auth: true it authenticates from the token file connect wrote (D699), since a timer inherits no shell exports. It is 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)
Windows %LOCALAPPDATA%\cartographer\tasks\sync.xml → Scheduled Task \Cartographer\Sync %LOCALAPPDATA%\cartographer\Logs\sync.log (via sync --log-file)

install is idempotent (it overwrites and re-registers); uninstalling a timer that is not installed is a success. The per-user scheduler belongs to the account, not to $HOME: when $HOME is not the account's home directory (a sandboxed run, the e2e suite), the server service and the sync timer write their unit files under $HOME and register nothing, so a sandbox never replaces the account's own jobs (#683); connect and setup then print sync timer not installed: HOME is not this account's home (expected in tests) as a plain line, not a warning. The timer runs plain cartographer sync: sync has no trust flag to give it (D906; workspace bind codex --trust is a separate, explicit command), and an MCP descriptor still needs the local approval an unattended job cannot give. setup and connect install the timer themselves when a connected provider has no session hook, or one limited to some sessions (D325); setup lists it in its plan. uninstall is the explicit opt-out, remembered as sync_timer_opt_out in the client state file (so connect does not reinstall it, and doctor's trigger check is info rather than a warning); install clears it. disconnect removes the timer when no remaining client needs it, without an opt-out. status only names the command.

On Windows the trigger is a repetition at the configured interval with start-when-available (systemd's Persistent=true analogue: a run missed while the machine was off happens as soon as it is on), and the interval reported by status is read back out of the definition on disk, so there is no second source of truth. The log file is what makes a failing background sync diagnosable there: a task's own history records exit codes only, and is disabled by default on many machines (D217).

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, a per-user Scheduled Task on Windows (D217). None of the three needs administrator rights. 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 restarts the unit and returns. 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 from a new process (its started_at differs from the one read before the restart, D266); --config selects the config used for that verification, and is otherwise unnecessary because the installed service definition is discoverable.

Windows takes the same three verbs to a different scheduler, so two behaviours are worth naming. stop also disables the task, because its logon trigger and its restart-on-failure would otherwise bring the server straight back; start and restart re-enable it first, which is why a service stopped on purpose stays stopped and a restart after a stop still works. And the graceful replacement is not a signal but a named event in the user's session, followed by an explicit relaunch of the task — nothing else would restart a process that drained and exited cleanly. restart and uninstall wait for the old server to exit before starting or unregistering the task, and from another logon session (SSH), where the event cannot be reached, the task is stopped instead and the command says so (D266).

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 cartographer-kb-import skill (internal/skillbundle/bundled/cartographer-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)
--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. Its author and committer are the operator, the identity git resolves for the KB's folder, else the server (D904, D950)
--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, so a corpus with many source directories needs one flag per subtree, not one per directory.

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: until it is rebuilt search returns nothing while concept_list sees everything. 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 — the block's placeholder paragraph points agents here, and to cartographer paths set when resolution fails (docs/sync.md §Path portability placeholders), as well as a standalone debugging tool.

When a key is not found, stderr carries, after the Error: line, one line naming the two commands that fix it (cartographer paths set <kind>:<key> <path> and cartographer paths suggest); stdout stays empty and the exit code is 1.

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

A leading ~ is expanded in both search_roots and paths entries, followed by either separator: ~/repos and ~\repos mean the same directory wherever the config is read. ~name is not expanded — another user's home is not something a config entry means.

A configured search root that does not exist, is not a directory, or cannot be listed produces a warning on stderr naming it and the OS error, and the remaining roots are still scanned. It is a warning, not a failure: a machine-local config may legitimately name a root that only exists on another machine. The warning is separate from the depth message below, so a typo in a root is not mistaken for a depth problem (D216). The same warnings surface in AppliedResult.Warnings when a {{repo:…}} placeholder is resolved during sync.

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).

cartographer config [--json] [--all]

Shows every setting with its effective value and where it comes from (D743), in two groups: the client settings of .cartographer.yaml (origin file when the key is written there, otherwise default) and, when the configured server answers GET /api/ui/v1/settings with the client's token, the server's: its keys, then one group per KB. One line per key: key value (origin), with restart required when the schema says so. By default only the keys something set are listed; --all adds the rest with their default. A per-KB key the KB's own settings.yaml sets shows origin kb, which beats file, env and flag (D746). A secret shows (set) or (unset). --json prints the raw structure (client, server, note). A server that is unreachable, refuses the token or predates the route costs a one-line note instead of the server group, and the exit code stays 0. The same data is the Atlas Settings page (control-plane.md §Read-only UI API).

The Atlas Settings page also shows the client, as sync last reported it: after every apply, sync posts a report to POST /api/client-settings (control-plane.md §Client settings reports): the client schema keys with their origin, the .cartographer.yaml as on disk, the connected agent clients (scope, entry kind, bound folders whatever the scope, and their KBs) and where each placeholder of the connected KBs resolves on this machine. No secret value, token or file content other than that config file. The server keeps it in memory for the sending principal only. Best-effort: a server that is unreachable, predates the route or refuses the token costs a debug line, never the sync (D818).

cartographer paths [list [--json]] | set <key> <path> | ignore <key> | unset <key>

The placeholder keys the last sync met, and the command that records where they live on this machine (D262). No network call: everything is read from the lockfile and .cartographer.yaml.

cartographer paths                          # = paths list: every key, its KB(s), path or failure reason
cartographer paths list --json              # {"placeholders": [{key, kbs, path | reason, description, default, ignored}]}
cartographer paths set path:kubeconfig ~/.kube/config
cartographer paths set repo:dotfiles ~/src/dotfiles   # answers `paths: repo:dotfiles -> <path>`: the echo carries the `<kind>:<key>` id
cartographer paths ignore repo:work-tools      # never exists on this machine: stop reporting it (D282)
cartographer paths unset kubeconfig           # remove a mapping, or an ignore
cartographer paths suggest                  # a candidate path, as a `paths set` line, for every unresolved key (D320)
cartographer paths --help                   # the usage above, on stdout, exit 0 (also `paths help`)
  • suggest reads the same unresolved, not ignored keys and prints, per key, its KBs, its description and one cartographer paths set <key> <path> line per candidate — or a line saying there is none. Candidates, in order: the paths.yaml default, when it exists on this machine; for a repo: key, every clone under search_roots whose origin remote contains the key name or whose directory is named after it (the near misses sync's exact match refused); for a path: key, ~/<name>, ~/.config/<name> and ~/.<name>. It writes nothing.
  • list shows every key recorded in the lockfile, resolved or not, with the KBs citing it (from sync_pull's placeholders list and the artifacts) and the local path or the reason it failed. When a bound KB declares the key in its paths.yaml (D263), an indented line below shows its description and declared default (both also in the JSON); the first, tab-separated line keeps its shape.
  • set writes paths: in .cartographer.yaml (clientconfig.Save: other keys survive). The key is accepted with or without its repo:/path: prefix and stored without it, which is how resolution looks it up — one map serves both kinds, paths: being checked before the repo cache and scan. The path is stored as written (~ kept, expanded at resolution time). A path that does not exist is recorded anyway with a warning (you may be about to clone it); for a repo: key, a path that is not a git clone is a warning too, never an error.
  • ignore marks a key as deliberately absent on this machine (D282), for a work repo on a personal laptop or a folder that exists on another host only. It is stored in ignored_paths: in .cartographer.yaml with its kind prefix (repo:work-tools); an unprefixed argument is qualified from the lockfile and refused (exit 2) when the lockfile does not know it. Resolution is untouched — the placeholder stays verbatim, and a key that does resolve still does — only the reporting changes: the sync warning, status and the connect step stop naming it, and list shows it as IGNORED ("ignored": true in the JSON) instead of counting it unresolved. A key mapped under paths: cannot be ignored (exit 1), and set on an ignored key lifts the ignore.
  • unset removes an entry or an ignore (exit 1 if there is neither).
  • set/ignore/unset print the one command that applies them — cartographer sync — and never sync implicitly. They need an existing .cartographer.yaml (exit 2, run connect first).

The TUI dashboard's per-provider sync reports the unresolved count in its done message; editing happens here, not in the TUI.

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 longest tool identifier it accepts (ToolIdentifierLimit, which connect and sync check), the client majors whose hook plugin shape is known (HookPluginMajors, which doctor checks), the global instructions files that shadow the managed one (InstructionsPrecedence, which doctor checks), the detection evidence (binary names, config directories in probe order, optional env-anchored config directories, optional per-GOOS application directories), 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. Every provider declares a config file and an emitter, and materializes under the shared base dir (D898).

A configuration that still names a client the registry no longer knows keeps working: Load drops the name (and its bindings) in memory, sync/connect/status/doctor print once <name>: this agent client is no longer supported …, the first writing command removes it from .cartographer.yaml and drops its lockfile section, and the files delivered earlier are never touched.

Files generated per provider (HTTP transport)

Provider Generated file Key
Claude Code .claude.json (also <dir>/.claude.json for each clients.claude.config_dirs, D755) mcpServers (JSON)
Codex CLI .codex/config.toml managed block [mcp_servers.cartographer] (TOML, marker cartographer:mcp:*)
Kiro .kiro/settings/mcp.json mcpServers (JSON)
OpenCode .config/opencode/opencode.json (an entry left in opencode.json by an earlier version is moved, #1172) mcp (JSON)
Google Antigravity .gemini/config/mcp_config.json mcpServers (JSON)
Crush .config/crush/crush.json mcp (JSON)
GitHub Copilot CLI .copilot/mcp-config.json mcpServers (JSON)

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; Crush uses type: "stdio" with a string command plus a separate args array. Copilot CLI uses type: "stdio" with command, args and env, and every entry carries "tools": ["*"]. 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://127.0.0.1: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://127.0.0.1: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://127.0.0.1:39273/mcp",
      "type": "http",
      "autoApprove": []
    }
  }
}

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

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "cartographer": {
      "type": "remote",
      "url": "http://127.0.0.1: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://127.0.0.1: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.

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

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

Crush — with auth (schema: https://charm.land/crush.json; Crush expands $VAR in config values, so the ${VAR} of the other providers is rewritten into its documented unbraced form):

{
  "$schema": "https://charm.land/crush.json",
  "mcp": {
    "cartographer": {
      "type": "http",
      "url": "http://127.0.0.1:39273/mcp",
      "headers": { "Authorization": "Bearer $CARTOGRAPHER_TOKENS" }
    }
  }
}

Crush's current configuration format is Bash (~/.config/crush/crushrc, executed at startup) and the JSON above is the one it documents as deprecated — written deliberately: merging into a client's config must be non-destructive, which a JSON object allows and a script does not, and an entry in crushrc would run in the user's session rather than be read (D225). Because Crush evaluates config values, a header or env value carrying a $(command) substitution is refused with an error naming the server and the key, instead of being written for Crush to execute on its next start. Crush has no documented user-level subagent directory, so that artifact kind is unsupported for it. Its hooks (hooks.PreToolUse in crush.json, D363) are registered by name; only PreToolUse fires, so there is no session event and its bootstrap trigger is the scheduled timer.

GitHub Copilot CLI (copilot, D676; probed on 1.0.94) — with auth. The file is ~/.copilot/mcp-config.json; Copilot expands ${VAR} in a header value from its environment, so the header is written as for Claude Code. "tools": ["*"] is required: without it a non-interactive session is offered no tool of the server:

{
  "mcpServers": {
    "cartographer": {
      "type": "http",
      "url": "http://127.0.0.1:39273/mcp",
      "headers": { "Authorization": "Bearer ${CARTOGRAPHER_TOKENS}" },
      "tools": ["*"]
    }
  }
}

Copilot's MCP tools are named <server>-<tool> (for example cartographer-concept_new). Its other global cells are ~/.copilot/copilot-instructions.md (a managed block), ~/.copilot/skills/<name>/, ~/.copilot/agents/<name>.agent.md (frontmatter name and description) and hooks. A hook is a dedicated file ~/.copilot/hooks/cartographer-<name>.json ({"version":1,"hooks":{"<event>":[{"type":"command","bash":…,"powershell":…}]}}) that Cartographer owns whole (the hook's own files sit in ~/.copilot/cartographer-hooks/<name>/, since Copilot parses every *.json below ~/.copilot/hooks/): it never edits ~/.copilot/settings.json. Only SessionStart, PreToolUse and PostToolUse are registered (sessionStart, preToolUse, postToolUse); Copilot has no matcher, so a KB hook that declares one is installed but not registered, with a warning. Cartographer reads $HOME/.copilot like every other provider and ignores COPILOT_HOME. The project-local MCP, agent and hook locations are gated on folder trust and are unsupported (D676).

The 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

Approval of MCP descriptors, hooks and executable files

Every MCP descriptor a KB ships, signed or not, needs a separate, local approval: an MCP server receives the agent's data, a signature proves only which server a descriptor came from, and since D906 any rw token on the KB can write a descriptor the server then signs. A KB hook, any KB artifact that ships a file with the execute bit, and a skill or agent whose frontmatter declares hooks or MCP servers (also in a providers: block) need the same approval because they run on this machine (D1141), signed or not. Nothing else the client installs waits for a decision (sync has no trust setting and no --auto-trust; the one explicit trust write is workspace bind codex … --trust for Codex project trust, D1074), and bundled artifacts never do. Inspect and record the current content with cartographer approve <kind> <name> --kb <kb> --yes (kind is mcp, hook, skill or agent; interactive terminals default to no confirmation; for a non-MCP kind the command lists the files and prints the hook.json and the content of the executable files). Approval records the source KB, artifact name (<kind>/<name> for everything but an MCP descriptor), content hash and timestamp in .cartographer.yaml; a content change requires reapproval. cartographer approve revoke <kind> <name> --kb <kb> is idempotent and takes effect on the next cartographer sync: for an MCP descriptor it prunes the managed provider config; for a hook or an executable file it stops refreshing what is installed, which is left in place, registered and running (the command says how to remove it). A hook or executable file that is waiting is reported as pending on every sync and is not refreshed, registered anew or removed until approved.

cartographer approve --pending [--kb <kb>] [--yes] approves every hook, skill or agent sync would report as needs_approval after one confirmation, and before it prints each of them exactly as the single form does (the files with their size and execute bit, the hook.json, the content of every executable file and of a frontmatter that declares hooks or MCP servers), so the confirmation covers what will run. An MCP descriptor is never approved this way: it is named with its own cartographer approve mcp <name> --kb <kb> command. Without a terminal it needs --yes, like the single form. Names, paths and targets chosen by the server are printed with every non-printable character escaped (\n, \x1b, \u202e), so a name cannot forge a line, rewrite the screen or read as another command. Then run cartographer sync.

One-time migration (D1166). The first sync of a client configuration that has never run it approves, by itself, a hook, skill or agent that needs approval, has no approval recorded and is already installed with the same content hash the KB serves now (the lockfile entry says so), and prints one line per artifact: approved <kind>/<name> [kb:<kb>]: installed before approvals existed, unchanged. An artifact whose hash differs from its lock entry, or that is not in the lockfile, stays needs_approval. The migration runs once: its marker, approvals_migrated, is a key of .cartographer.yaml beside mcp_approvals (so losing the state file cannot re-arm it), is never cleared, and is not set by a dry run or by a sync that could not pull every KB, so a later approve revoke is never undone. A sync -dry-run before it previews it: would approve <kind>/<name> [kb:<kb>]: installed before approvals existed, unchanged for what the real sync would approve, needs_approval only for the rest, and the marker stays untouched (#1176). A first connect of a client with no agent and no install yet writes the marker itself, so its first sync does not rewrite .cartographer.yaml (#1302); a client that is already connected keeps it unset until its own first sync.

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 (the connected providers are in the client state file, below). One file per machine, not per project: this avoids drift with a provider connected in one repo but not another.

server_url: http://127.0.0.1:39273/mcp
server_name: cartographer  # name under which the server is registered in the MCP configs (always "cartographer", overridden only by editing this file)
auth: false
token: ${CARTOGRAPHER_TOKENS}   # a literal token or ${ENV}; a literal keeps this file at mode 0600 (D906). Unattended runs fall back to the private token file
clients:         # per-provider KB binding (D169); absent provider = every known KB
  claude:
    kbs: [homelab]
    config_dirs: [~/.claude/accounts/b]   # extra Claude Code config dirs (CLAUDE_CONFIG_DIR) whose .claude.json also gets the MCP entry (D755); claude only; does not make the binding explicit
search_roots: ["~/Documents", "~/src", "~/code", "~/projects"]   # where repoindex.Scan looks for git clones for {{repo:<key>}} (D75); the default, 4 levels deep (a constant, D906); a default root that does not exist is skipped silently; consulted only for a key with no `paths` entry
paths: {}                       # manual name -> path mapping for {{path:<name>}} (and an override for {{repo:<key>}}, D75); edit with `cartographer paths set`
ignored_paths: []               # placeholder ids known to be absent on this machine, e.g. repo:work-tools (D282); edit with `cartographer paths ignore`
identities:                     # D904; the git author of this machine's writes, by KB name or default; without an entry, the identity git resolves in the session's repository (an HTTP entry: the global one)
  default: Ada Lovelace <ada@example.com>
  work-kb: Ada Lovelace <ada@corp.example>   # an entry decides: "" sends no author (the server names the token), an invalid one sends none and `sync` warns
allow_insecure_http: false      # D1023; written only when true. true: accept a plain http:// server_url that is not loopback (the token then travels in clear); set by `connect --allow-insecure-http`
update_check: true              # D254, D906; written only when false. false: no update lookup at all. The client never installs a release by itself
usage_scan: true                # D326; written only when false. false: sync reads no session transcript and reports no skill usage (docs/sync.md §Usage scan)
scopes: {}                      # per-provider scope, provider | workspace (D193); set by `cartographer workspace bind`
workspaces: {}                  # per-provider explicit workspace bindings, [{path, kbs}] (D193); set by `cartographer workspace bind`
signing_keys: {}                # Ed25519 public keys pinned per source KB (`connect --pin-key`); never learned from a sync
mcp_approvals: {}               # local approvals of MCP descriptors, bound to the content hash; written by `cartographer approve`, not edited by hand
approvals_migrated: false       # set by the first sync once it has approved what was already installed unchanged (D1166); never cleared, do not edit

Every key, with its values and default, is the Schema table in internal/clientconfig/schema.go.

The write-findings hook (D353) is always installed (Claude Code, Codex, ...); docs/sync.md §Layer 1.

The file documents itself (D747). Every key it holds carries a comment (what it does, then one line with its values, default and notes), and the keys that are not set are listed commented out under # --- available settings: uncomment a key to set it ---, like a Helm values.yaml: a blank line between keys, each with the value to uncomment, and an example under a map or a list (# scopes: / # claude: workspace). Files written before that wording have # --- available settings ---; a realignment reads either divider and writes the new one. The text comes from one table, Schema in internal/clientconfig/schema.go, which is also what config.example.yaml and the config view read; a test fails when a key exists in the file without an entry there, so a new setting starts in the table. Save is a yaml.Node round-trip of the existing file: a comment you wrote and a key the program does not know survive every save, a key you had commented out leaves the block when it gets set, and a file written by an older version gains the comments at its next save. A save that changes no setting does not rewrite the file. A file with no comments loads exactly as before: comments are additive.

Realignment on sync (D903). The first cartographer sync after an upgrade that removed a key brings the file up to date, under the client-state lock: a removed key is dropped, a value that switched a feature off is reset to the default, the comments above are written, every other value, comment and key order stays, and the original is kept beside it as .cartographer.yaml.bak-<version> (a numeric suffix when one of that name exists). sync prints what it changed; a second run changes nothing and writes no backup, and sync --dry-run writes nothing. The registry of removed keys is Retired in internal/clientconfig/realign.go, next to the schema. A removed key met later, edited back in by hand, is ignored with a warning that names its replacement; it is never an error.

The registry holds the client keys token_env (rewritten as token: ${NAME}, comments kept), trust, write_findings_hook, search_depth, update.check and update.policy (update_check, which an auto-patch policy turns into true), and agents and sync_timer_opt_out, whose values move to the client state file before the YAML drops them. A realignment that cannot write the state file stops and leaves the keys where they are. Until a file is realigned (a read-only mount), Load honours the old token_env, update.check, agents and sync_timer_opt_out.

Machine-written records live in .cartographer-state.json, next to .cartographer-client.lock and .cartographer-sync.lock.json (D747). known_kbs (the KB names /health advertised at the last connect/sync), server_mount_mode and server_routed_path are rewritten wholesale by every sync, which is why they left the file you edit; since D906 it also holds agents (the connected clients, maintained by connect and disconnect) and sync_timer_opt_out (a record of service sync-timer uninstall), for the same reason: they are written by commands, never by a person. Because of them it is not disposable: a deleted state file means no connected client until the next connect. A sync that only refreshes them writes the state file and leaves .cartographer.yaml byte-identical, mtime included. The state file is JSON, never hand-edited. An installation from before keeps these keys in .cartographer.yaml; Load reads them from there while the state file is absent, and the next save moves them (the legacy kbs key, read since D169, goes the same way). An unreadable or corrupt state file is treated as absent, with one line on stderr: the caches in it are rebuilt by the next sync, the connected clients by connect. clients is user-owned: sync never writes it, and connect writes a provider's binding only on its first connect, when it has none (the KB selection, --kb); it is otherwise maintained with cartographer client (below).

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; for claude also a config dirs line: $HOME (default), each declared dir, and an undeclared CLAUDE_CONFIG_DIR marked not declared
client bind <provider> <kb>[,<kb>...] adds to the binding (the output lists the resulting binding and what this call added); creating the first binding narrows the provider from "every known KB" to only those listed, and the output says so; it asks the server for its KB list once and refuses a KB the server does not mount (without rewriting the cached list, sync owns it, D721); with the server unreachable it falls back to the cached list and only warns; duplicates in the list are stored once
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
client update [--check=true\|false] shows, or sets, update_check (D254, D906)

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": "9f86d081…", "server_version": "1.4.0", "managed": [ /* ManagedFile[] */ ],
                "resolved_placeholders": { "repo:tool": "/home/user/src/tool" },
                "unresolved_placeholders": { "path:kubeconfig": "no \"kubeconfig\" entry under paths: (.cartographer.yaml)" },
                "placeholder_sources": { "repo:tool": ["kb-a"], "path:kubeconfig": ["kb-a"] },
                "placeholder_decls": { "path:kubeconfig": { "description": "kubectl config" } },
                "paths_section_hash": "…" },
    "opencode": { "applied_revision": "9f86d081…", "server_version": "1.4.0", "managed": [ /* ManagedFile[] */ ] }
  },
  "workspaces": { "/path/to/repo": { /* provider → Lock: a separate namespace */ } }
}

applied_revision is the bare hex sha256 of the manifest. A lock also carries base_dir and project_scope for a workspace projection, and session_hook_limit_acked (field by field in docs/sync.md §Core concepts).

The old v1 format ({"applied_revision", "provider", "managed"}, single provider) is automatically migrated on read (provisioning.ReadLockFile) into {"providers": {<provider>: {...}}}. See docs/sync.md §Core concepts 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://127.0.0.1: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 Connect (if not connected) or resync the selected provider
s Resync the selected connected provider (no-op on a disconnected one)
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 / ctrl+c Quit

d on a connected agent opens a confirmation screen (y confirms; the arrows, tab, h and l toggle Yes/No and enter confirms only when Yes is selected; n, Esc and q cancel and return to the list; any other key is ignored); 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. 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).

Symlinked config files and directories are refused

Every write, mkdir and remove under a client directory goes through internal/safewrite, the same guard that protects the provisioned artifacts (sync.md §Security): the provider's MCP config file and the Codex config.toml blocks connect, sync and disconnect edit (internal/blocktext), the instructions block, the hook registrations (settings.json, hooks.json, the Kiro, Copilot, Crush and OpenCode files), the files disconnect and a sync prune, and the workspace's .git/info/exclude. os.WriteFile on a symlink opens the link's target, and os.Remove or os.MkdirAll under a linked directory act in the target too, so a config file that is a link, or a directory above it under the client base dir that is one (a dotfile manager, a shared checkout), would send the operation somewhere else. Both are refused, with an error that names the path and says to replace the link with a regular file or directory; so is any other non-regular file or non-directory (device, socket, named pipe, Windows reparse point). The base dir itself ($HOME, or the project dir in workspace scope) may be a link, and a path that does not exist yet is created.

A refusal needs something to write or remove: a config with no entry or block of ours in it is a no-op under a link too, never an error, so a dotfile-managed ~/.codex/config.toml that needs no change stays quiet. --dry-run refuses too, and previews a prune's refusal for the managed file itself and for a shared config file that exists; it can flag a linked MCP config the real run would leave untouched, and it does not check the file that holds a hook's registration.

The refusal is per provider (and, in a sync, per artifact): - connect and sync report it as a warning (mcp config left unchanged: …, or <kind>/<name> not installed: … / not removed: … for a provisioned artifact: a skill, an MCP entry, the instructions block, a hook and its registration) and still write every other provider and artifact. The bootstrap and write-findings hooks are no exception: a provider whose destination for them is behind a link gets a <provider>: bootstrap hook not installed: … warning and the others are still set up. A refused write of a new artifact is not recorded as installed, a refused update keeps the previous lockfile entry (its files on disk are still ours), and one whose removal was refused stays in the lockfile, so the next sync retries each. A hook's script is only removed once its registration is. When a stale per-KB entry could not be removed, that KB (only) stays in known_kbs until the entry is gone. A workspace's info/exclude behind a link, or a .git file that leads to no git directory, is a warning, not an error. - disconnect prints kept connected: … for the provider, leaves its lockfile section, .cartographer.yaml entry and managed files (whatever could be pruned is) in place so the next disconnect retries, removes the others, and exits 1. The TUI shows the same line. reconnect does not rebuild a provider its disconnect half kept, rebuilds the others, and also exits 1.

Nothing is written through the link until the operator replaces it. The legacy .codex/config.json that disconnect cleans up is checked before config.toml, so a refusal there leaves both untouched.

Installation

One channel per platform, and an upgrade uses the one the binary came from:

brew install beppetemp/tap/cartographer     # macOS (cask from the tap)
irm https://raw.githubusercontent.com/BeppeTemp/cartographer/main/install.ps1 | iex   # Windows (PowerShell, D252)
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 against the release's sha256sums.txt (a missing manifest is an error, D1022), and installs it into /usr/local/bin (or ~/.local/bin if not writable). Also supports update and uninstall as the first argument. Run from a Windows shell (Git Bash, MSYS2, Cygwin) it refuses and prints the install.ps1 command instead. install.ps1 is the Windows counterpart (install/update/uninstall, per-user, checksum-verified). See docs/deployment.md §Client installation.