# IdeaSpaces CLI

> The command line for folders that agents inhabit.

An ideaspace is a folder of Markdown under git that holds **your knowledge** and **how to work with it**. Open an agent inside it and that's who you're talking to. The [protocol](https://github.com/IdeaSpaces-xyz/ideaspace-protocol) defines the shape.

Most people use the shape through a plugin for [Claude Code, Codex, or Cowork](https://github.com/IdeaSpaces-xyz/claude-code-plugin), or [Pi](https://github.com/IdeaSpaces-xyz/pi-is-space). The plugins bundle this CLI. Install it yourself for scripts, automation, and other harnesses.

A space is a real git repository on your disk. Nothing leaves your machine until you publish or push.

## Install

```sh
npm install -g @ideaspaces/cli
```

Node 20+ and git. `ideaspaces doctor` checks both.

## Five minutes

```sh
ideaspaces create my-space --yes       # a folder with _agent/ in it, committed
cd my-space
ideaspaces navigate .                  # what is here, what changed since last time
ideaspaces look . --depth children       # one target at a chosen rung
ideaspaces write decisions/pricing.md --name "Pricing" --content "# Pricing

Per seat, billed yearly."
ideaspaces commit -m "Capture the pricing decision" decisions/pricing.md
```

`create --home <dir> --yes` makes a dedicated Home with an Agreement and an empty `home.map.md`; it refuses code repos and preserves an existing `home.map.md`. `map <dir>` opens that curated file even before it has roots, and `navigate <dir>` names it. Without a Map note (`*.map.md` or a `README.md` with a `map` block), `map` derives the local tree as before. `create` writes one contract file, `_agent/agreement.md`, under the knowledge kind — or the agent kind with `--agent` — and references that kind's public Agreement Space in its frontmatter (`agreement: knowledge:repo:…`). Its sections are prompts to replace in conversation; `navigate --json` reports the reference as `agreementReference`. Older Spaces on `_agent/foundation.md` still load; `navigate` prefers Agreement when both exist, and `--contract foundation` reads the older frame explicitly. `create --foundation` writes the older shape for one more release. Ambient navigation renders the protocol-owned stable `head`, then local working-set/catalog handles, then the volatile `tail`; activity belongs only to the tail. `look <path> --depth name|summary|surface|children|full` reads exactly one local Note or directory beneath a reference-only frame; the existing `navigate --focus` and `inspect` readers remain available during convergence. `create --agent` makes a folder that *is* an agent. Inside a code repo, `create --yes` keeps `_agent/` local to your machine; `--shared` commits it.

## Take one home, hand one over

```sh
git clone https://github.com/IdeaSpaces-xyz/hn-reader && cd hn-reader    # any git host
ideaspaces fork https://ideaspaces.xyz/repos/<repo-id> ./theirs         # public space, no account
ideaspaces update --yes                                                   # later: pull in source changes that don't conflict
```

```sh
ideaspaces login
ideaspaces publish --yes                                     # host it, private to you
ideaspaces share person someone@example.com --grade explore
ideaspaces share visibility public --yes                     # anyone can view and fork
ideaspaces push
ideaspaces pull
```

Anything that leaves your machine prints its plan and runs only with `--yes`.

`share person`, `team`, `list`, `remove`, `resend`, `history`, and `visibility` manage recipient access without exposing repository memberships. The inspection commands deliberately expose raw internal grant records to an authorized reader under `--json`, unlike the recipient-shaped write commands:

- `share status` crawls the root and every hosted path with Node identity, plus direct grants. It fails rather than report incomplete coverage. The client-side crawl may take a while on large Spaces until a server aggregate exists.
- `share log [--node <id>]` reads **only that node's grant events**, not a repo-wide activity log or publication/ownership history. Its JSON declares `scope: "node_grants_only"` and `complete_repo_log: false`; select by scope rather than assuming a future repo feed has this shape.

Neither inspection command changes access.

## Commands

`ideaspaces <command> --help` for usage. `--json` on reads and local writes.

| Job | Commands |
|---|---|
| Look around | `navigate`, `look`, `status`, `inspect`, `ls`, `search`, `skills`, `map`, `times` |
| Write things down | `write`, `commit`, `change`, `node` |
| Start, bring home | `create`, `get` (`clone`, `fork`, `link`), `clones`, `forget` |
| Share and sync | `login`, `status account` (`whoami`), `publish`, `share`, `push`, `integrate` (`pull`, `update`), `sync`, `repos`, `catalog` |
| Talk | `conversation`, `conversations`, `threads` (`inbox` legacy alias for one release), `spaces`, `agents`, `agent list`, `agent runs`, `agent record` |
| Run a local agent | `agent run` (`--runtime=pi` or `--runtime=claude`, `--model`, `--parent`, pinned local `--thread`), `agent runs` (query run records), `agent record` (append/update run records), `agent capabilities`, `pi-status`, `pi-login`, `pi-logout`, `pi-models`, `claude-status`, `claude-models`, `conversation send --local` (`--runtime=pi`, the default, or `--runtime=claude` for your own Claude Code), `conversation compact --local` |
| Housekeeping | `status doctor` (`doctor`), `credential`, `power logout` |

## What the CLI promises

- **`write`** touches only the file you name and keeps frontmatter you did not set. `--map JSON|YAML|FILE` writes a validated Map block to a Note. Pass the returned `sha` as `--if-match` for a safe second write; `--force` overwrites.
- **`commit`** commits only the paths you name. Other staged work, yours or a teammate's, is left alone. The author is git's `user.name` and `user.email`, never a hidden credential.
- **A space has one id.** Agreement is the preferred contract source; until it declares identity, a valid Foundation identity remains the compatibility evidence for that same Space. `create` mints identity into the Agreement it writes (or into Foundation under `--foundation`), `publish` adopts it, and conflicting declarations fail closed. `clone` keeps identity; `fork` remints it in every projected root entrypoint. Nothing rekeys a space silently.
- **`get` shows before it does.** Given a Space URL it says what clone would do (same identity, fetch/push truth, full history) and what fork would do (new identity, no source history), and mutates nothing until `--yes --as clone|fork` names one; given a folder it offers `link`. It offers only a mode your account can reach; otherwise it says which access (Viewer, Allow copying, Editor) would open one. The choice between clone and fork is yours, never guessed. `clone`, `fork`, and `link` remain the explicit verbs.
- **`integrate` follows what the checkout is.** A clone integrates its upstream; an unpublished fork integrates its maintained source; a published fork with source lineage defaults to its own remote and takes `--from source` for the other. Plan-first: `--yes` applies. `pull` and `update` remain as the underlying verbs.
- **`status` is the tail, and only the tail.** It renders local State (branch, upstream, working tree, captures awaiting commit), the repo catalog when you pass `--workspace`, and what moved since last session — the same composition an agent runtime appends after its cached head, so the two never disagree. Nothing `navigate` already showed in the head. Login state and installation health are separate sections: `status account` and `status doctor` (`whoami` and `doctor` still work for one release).
- **`look` deepens one target without adopting its terms.** The applicable Agreement or Foundation is reference context only. JSON adds a portable `map` only for a clean, pinned, identified root; dirty, unborn, ignored, local-only, or invalid roots remain honest local projections.
- **Read through a Map, not a folder.** `look @notes//ideas/first.md --map space.map.md` reads a Map member by address — `@<root name>//<position>`, `@<root_node_id>//<position>`, or `//<position>` for your own root — with no filesystem path, from any working directory. `navigate @notes//ideas --map space.map.md` focuses on a member directory the same way. A Space's Map reads each root's checkout at HEAD and says when it has drifted from the pin; a Thread's Map (one under `_threads/`) reads the pin; `--at pin|head` overrides either. The bytes come from the commit, never the working tree, and the result names the root and carries its identity form, so what you keep resolves without the Map. A root with no local checkout fails with its name and the reason. `look <path> --pin <sha>` reads your own checkout at an authored commit.
- **A launch Map is read, not just listed.** `agent run` and `conversation send` with `--map <note>` start the session with each member read at its declared depth, within 12,000 characters: over that, the last member is read at a lower depth first, then the one before it, and nothing is cut mid-member. The same Map at the same commits gives the same bytes. The launched session gets the Map as `IDEASPACES_MAP`, so an address read there needs no `--map`. For a Space Map, its roots are the Space: the first locally resolved root is the default folder for new material; all locally resolved roots join Claude's `--add-dir` reach. The shell still starts in the selected POV, not the first root, so use absolute paths or change directory explicitly. Pi has no `--add-dir` equivalent: its process can reach local paths through normal OS permissions, while `added_dirs` remains empty in disclosure; it does not treat Map pointers as grants. The Map's storage folder is never material; a Map with no roots is an empty Space and the agent can offer to add a root. A Map is starting context, not an authority boundary: the selected Map's locally resolved roots are passed to Claude as `--add-dir` and disclosed in `message_start.added_dirs`, widening file-tool reach subject to runtime permissions. A pointer alone grants no OS permission; do not open untrusted Maps as a way to grant directories.
- **`agent run <pov> --message <text>`** launches from an explicit local Agreement POV path under Pi or Claude and streams Keeper events. It passes that POV's own Agreement as child orientation; `--model`, `--pi-thinking`, and supported Claude `--claude-effort` are runtime choices, not task modes. Pi child runs require explicit `--ext <paths>` (and `--skill <dirs>` for selected package skills). They disable discovery of both Pi extensions and skills; only the forwarded paths load, not additional user or target-project skills. Relative paths resolve from the selected POV, not the caller's cwd. Installed packages or ambient `IDEASPACES_PI_EXTENSIONS` do not authorize a child launch. Missing selection refuses before spawning. A supplied `--conversation <id>` must already have a nonempty transcript under that exact POV and runtime; omit it for the first turn. For a new Pi transcript, `--topic <name>` sets its durable Pi session name (at most 200 characters); without it the first message still names an unnamed session. Resume with `--conversation` keeps the existing name. Claude rejects `--topic` rather than ignoring it. A named launch waits up to 10 seconds for Pi to confirm its name before sending the first prompt; that handshake limit is not a turn deadline. **Pi trust defaults to saved** on both `agent run` and `conversation send --local` (no silent `-a` flag unasked); pass `--pi-trust explicit` only when a person explicitly approves project resources. For Claude, launch reach automatically includes the enclosing Space root (also when the POV is its own nested Git repo) and locally reachable checkouts named by the POV or Space Map. Repeat `--reach <dir>` for an additional directory; relative reach paths resolve from the caller's cwd, and names containing commas remain intact. The first JSON `message_start` prints cwd, added_dirs, permission_mode, allowed_tools, runtime and model, plus `model_requested` (the override passed, or null) and `model_observed` (what Pi's `get_state` or Claude Code's init reported for this turn, or null). `model` is the older field: the override, else the runtime's tier label, which under Pi is `local` — not a model that ran. Writable runs preapprove Read/Grep/Glob, IdeaSpaces plugin tools, Edit/Write and git/ideaspaces Bash patterns. `--read-only` exposes only Read/Grep/Glob builtins and suppresses all MCP servers (`--strict-mcp-config`) to exclude unknown effects; it cannot call `is_look` in that mode. It is not a filesystem sandbox. On writable hand-overs, `--allowedTools` pre-approves rather than restricts; the shell is not a harness security boundary. The hand-over is bounded by the declared directories for file tools, first-line disclosure, and the owner's Agreement and dispatch. `agent run` refuses `bypassPermissions`; Claude Code 2.1.291 cannot set `auto` reliably by flag. Pi has no purpose-level tool scoping: its first line reports the forwarded extensions and trust, with `added_dirs: []`, `permission_mode: null` and `allowed_tools: null`. `agent run` bounds messages to 8 KiB and the combined contract/Thread orientation to 16 KiB before child spawn rather than silently truncating a contract. Every `agent run` writes a run record queryable via `agent runs [--parent <id>] [--json]`.
- **Pi retries stay inside a turn.** A Pi `agent_end` with `willRetry: true` is an intermediate provider failure, not completion. The CLI waits while Pi retries with bounded backoff in the same transcript, preserving completed tool steps. When the final attempt fails, Pi still emits its existing `pi_error`; an RPC process exit names the prior retry cause and the conversation to resume. Completed turns report Pi token usage and cost through SDK 0.5.0. A started `agent run --json` that returns with neither a terminal event nor a process error emits `run_incomplete` instead of a false success. Preflight refusals remain on stderr; a deliberate abort remains `cancelled` with a cancelled run record, but now exits nonzero so launchers do not count it as success. A killed process or closed output pipe cannot emit a terminal event.
- **`agent runs`** reads local agent run records (`--json`, and filtered by `--parent <id>` or `--agent <path>`). Each record captures run id, parent id, parent tool use id (when present), agent POV folder, harness, observed/requested model, conversation id, status, start and end times.
- **`agent record --run-id <id>`** writes or updates a local run record from external sessions or hooks (e.g. Claude Code SessionStart/Stop or Pi extensions). Updates merge into the existing record by `run_id`. Accepts `--parent <id>`, `--agent <path>`, `--task <text>` or `--message <text>` (derives task), `--harness pi|claude`, `--model <name>`, `--conversation <id>`, `--status running|completed|failed|interrupted|cancelled`, `--started <iso>`, and `--ended <iso>`.
- **`agent capabilities --json` is what launchers check.** It prints `{schema: "ideaspaces.agent-run.capabilities", version: 1, features: [...]}`: `pi.explicit-extensions`, `pi.retry-terminal`, `pi.skill-isolation`, `pi.trust`, `pi.thinking`, `claude.read-only`, `claude.effort`, `claude.permission-mode`, `model.requested-observed`, `run.records`, `run.terminal-error`, `run.cancelled-nonzero`. A listed feature keeps its meaning; new behaviour gets a new name. A launcher should require the features it relies on and refuse when the command or a feature is missing — never search `--help`, which is written for people and is reworded between releases.
- **`conversation send --local --thread <path|x_id>`** privately launches beside a Thread on each turn. A local path with no Map reads the live Thread timeline and the full last-post body (the receipt counts one supplied body); an authored `--thread-map <note> --thread-member <ordinal>` reads that selected post at its pin. Hosted `x_…` reads under the signed-in person's grant. Neither conversation launch posts a reply; the person sends it explicitly. Other participants' posts are untrusted data, not instructions to the agent; review any proposed answer before granting it reach or posting. Overlarge frames fail before starting, and a partial Map/member pair is refused.
- **`agent run --thread <path> --thread-map <note> --thread-member <ordinal>`** launches from a selected authored Map member (zero-based ordinal), never from the Thread path alone or HEAD. `--thread-map` names a regular authored Map file (not inline YAML), and `--thread-member` is its zero-based member index. The member must name a post in that local Thread at an available commit; the Thread Agreement and selected post summary are read at the pin, separate from the user's message. A successful run appends its closing response as a snapshot under the POV Agreement's name, citing that pinned member in its Map; a failed run writes nothing. The post id and path appear in `turn_complete.result.thread_snapshot` in the Keeper JSONL stream (and on stderr in human mode); a failed append emits a terminal error instead of `turn_complete`. Hosted `x_…` Threads may be read by `agent run --thread x_<id>` into a private conversation; no reply is posted. Their full message bodies and participants are fetched at launch, subject to a 12 KiB context cap; only the person sends a reply through `threads reply`.
- **`threads` is one family for local files and hosted `x_` ids.** `threads list` shows local and hosted rows together when logged in; `threads posts` lists hosted posts across Threads newest first (or `--order older`) with `--limit`, `--since`, `--cursor`, and `--space` or `--scope`.
- **Read local Threads progressively.** `threads open <slug|path>` reads a local Thread at five depths (cold default `surface`: newest snapshot/reframe and a bounded tail; `summary`: parent post and newest snapshot summary; `children`: branch roots/tips and attached child Threads; `full`: paged). `--limit` and `--order newer|older` page local posts or branches (post ranges default to older/chronological; child branches default to newer). `surface` counts its snapshot against `--limit` (so `--limit 1` shows only the head), keeps the newest tail, then reverses the selected page as a whole for `--order newer`. `--since <post-id>` advances forward; pair it with `--order older` to continue a page, not newer-first. `--post <id>` reads one exact post plus its child Threads. Local `surface` ranges shorten large bodies, mark `body_truncated: true` in JSON and point to `--post` for exact bytes; `full` and `--post` keep bodies exact. `child_threads_total` and `branches_total` count results beyond the shown page; `child_threads_complete: false` means a sibling could not be read, with a stderr warning rather than a false claim of no children. `threads list --json` reports `local_complete: false` when posts were skipped, while still listing their Thread with warnings naming the file and field. If an opening post cannot be read, its parent has `status: unknown` and writes refuse with a repair hint; a later unreadable post leaves `closed: "unknown"` but does not block writes. The JSON Thread record carries its parent from the opening post's optional `attached_to` (or the legacy Agreement): only `post:` or a bare post alias establishes a child edge. Other typed links are kept with `status: none`; missing/ambiguous post parents are never guessed. Hosted `threads read <x_…>`, `send`, `reply`, and `expand` keep the wire unchanged. Same-Space `open --pin <40-hex-sha> --position _threads/<thread>/<post>.md` remains a low-level pinned read; an unavailable commit never falls back to HEAD.
- **Read and page with `look` (CLI 0.4.0).** `look _threads/<slug> --depth children --limit 50` returns `target.page` with `total`, `shown` and `nextCursor`; pass that filename to `--since`, with the same `--order older|newer`, for later pages. Ordinary Content directories page too. `look _threads/<slug> --post msg_…` reads one post by full id or unique eight-hex shorthand; `look --post msg_…` searches only the caller-supplied Space root, with `unresolved`/`ambiguous` instead of a guess. At a Map pin, `look @root//_threads/<slug>/msg_… --map <note> --at pin` selects the same post without HEAD fallback. Use `threads open` instead for the reply graph and cursor acknowledgment.
- **Write and find local Threads.** `threads new`, `post`, `close` (a closure post), and `render` work without a login. `threads new <slug> --about <title> [--attached-to <type>:<id>] [--map <pinned-note>]` writes the optional open link **and** the authored Map on the opening post; an invalid or unpinned Map refuses before the folder is created. `post:msg_…` writes a child edge while `note:@…` remains metadata. Posts are exclusive-create Markdown; commit and push their exact paths through the repo's usual Git flow for a second machine to see them. Local list and open return `thread_id` (the opening post id; `null` for an empty legacy Thread) alongside the current path. `threads locate <thread_id> <space-root> [<space-root> ...]` searches only the roots you supply, returns `found`, `not_found` or `ambiguous`, and never silently picks a copied Thread. A local agent turn reports verified Thread reads and writes in `turn_complete.result.workspace.threads` with `thread_id`, current path and action: successful `is_threads` list/open/post/close and direct `_threads/` file reads/writes. Shell commands and failed tools are not inferred. `threads post` without `--reply-to` branches from the opening post and says so on stderr; `--reply-to latest` explicitly replies to the newest post. Its JSON result includes the chosen `in_reply_to`. Reply to the post you answer, or branch from the post a new topic serves. `--tags friction,ux` writes retrieval tags to the immutable post. `post --kind mark --mark claimed --reply-to <one-post-id>` folds a mark onto that target, out of the timeline; a `correction` with `--supersedes <mark-id>` retracts it. Unknown post kinds remain readable and authorable. Writers validate all frontmatter fields through the protocol before creating a post. Unknown kinds remain valid to readers; a writer needs explicit `--yes` to author a custom kind, so a typo cannot silently become immutable history. A `--message` containing repeated literal `\\n` sequences is refused with a quoting hint rather than writing escaped text; to intentionally post literal escapes, pipe the body on stdin. `--reply-to` fills the ancestor chain, `--map` accepts a validated authored Map, and `render` derives the timeline without overwriting the curated README. `open --new` uses a private per-thread cursor; only `--ack` moves it. Because cold `surface` tails and may shorten bodies, `open --new --ack` can refuse as incomplete; use explicit `--depth full --new --ack` when the complete exact unread range fits a page. Bounded/shortened partial pages and surfaces that skipped unseen posts before a snapshot cannot be acknowledged.
- **Local JSON change in 0.2.21 (`threads open` only):** `schema: "ideaspaces.local-thread.open.v2"` marks the new local response; cold `open` defaults to `surface`; unfiltered `summary` returns a parent and snapshot coordinate with `posts: []`; `children` returns bounded `branches`/`child_threads` rather than a recursive `posts[].children` tree. Consumers parsing the old local `posts` shape must select their rung explicitly; `--depth summary --limit N` still gives bounded post summary rows. Hosted Thread JSON is unchanged. Since 0.3.0, `look` on a Thread folder instead uses the protocol's canonical content-look target at five rungs; it does not return the `threads open` timeline JSON. Use `threads open` for paged posts, parent and child-Thread rows. **In CLI 0.3.1, local `threads open` is v3**: `closed` may be `"unknown"` (a truthy string, so consumers must compare `closed === true`), `warnings` and `unreadable_posts` name skipped or degraded posts, and marks fold onto their target rather than appearing in the timeline. The mixed `threads list` response carries `schema: "ideaspaces.threads.list.v2"`; its local and child rows also allow `closed: "unknown"`. `count` counts every readable immutable post including folded marks, so it may exceed the displayed timeline length. `local_complete: false` flags skipped posts even while the healthy part of their Thread stays listed; field warnings alone leave it true because the post was read. Search over those surviving posts can now exit 0 instead of treating the entire Thread as unreadable.
- **Send a hosted Thread on a message alone.** `threads send @handle --name 'Question' --summary 'Why now' --message '# Question\n…'` needs no published Note, `--about`, or Map. A recipient is required without a subject. Optional `--map <selection.json>` can contain only HTTPS/hostname/Thread address members and no target, so links travel with the opening message; position members need a target. `--share` without target/Map roots also needs `--share-roots`. If the server has not yet deployed message-only Threads, a 422 naming `target_node_id` says to wait for the API rollout or send a targeted Thread with `--about` for now.
- **Select a pinned local Thread across Spaces.** Use `open <slug> --map <file> --member <ordinal>` to read only an authored pinned post, including one in another Space, without leaving the caller's Agreement; `--depth full` returns the pinned bytes, not the live Thread, and selected reads do not support live `--new` or `--ack`. A unique registered checkout resolves the Map root; for an unregistered or multiply checked-out Space, pass `--checkout <absolute Space root>` explicitly. A Map-selected read requires a verifiable root identity even when the Thread is in the caller's Space; that caller Space can serve as its own checkout without a registry entry, and same-Space paths still work once identity is established. Unlike earlier same-Space `open --map --member`, selected `posts` contains only the pinned post and `thread.closed` is not reported from the live tree; unselected same-Space `open` and explicit `--pin/--position` retain their live timeline. For an orphan `_threads/` worktree, give the parent Space root as `--checkout`, not the orphan worktree directory. Symlinked `_threads/` is refused; use the real orphan Git worktree instead. The checkout's root identity and any canonical origin or registry evidence must agree with the authored Map root.
- **Reply to the selected Thread.** `post <slug> --map <file> --member <ordinal> --reply-to <existing-pinned-post-id> --message <body>` rechecks the live Thread before appending under the caller's Agent Agreement name; a newer post need not be the parent. A changed/closed Thread, missing parent, missing pin, symlink or arbitrary cross-Space path refuses. A changed Thread README or Agreement needs a re-authored Map at an updated commit. The late live-target recheck is best-effort against concurrent edits, not a cross-process lock; creation is exclusive. `--map` without `--member` on a same-Space post remains a citation, not target selection.
- **Private Threads stay off the default remote.** For a private code repo, `threads init` mounts an orphan `threads` branch at ignored `_threads/`; `threads push --remote <team-remote>` explicitly names a non-origin destination on `git.ideaspaces.xyz` (or a local file remote for offline testing); unknown hosts, including GitHub Enterprise domains, are refused rather than guessed safe. Never publish that branch to GitHub. The `inbox` alias prints a deprecation notice for one release; the server's `/inbox` API stays unchanged.
- **`search --json` seals its hits as a Map under the same gate.** Ranked results always come back; `map_status` is `available` with a parse-valid `map` only for a clean, pinned, identified root with every hit tracked at an unchanged HEAD, else `projection_pending` with `map: null`. Rank, score, and snippet stay in `results`; member order carries the rank.
- **`map` opens a curated `*.map.md` or a folder's `README.md` with a `map` block.** Pass the file to select one lens explicitly; a folder prefers `home.map.md`, then a Map-bearing `README.md`, then another `*.map.md`. `navigate <folder>` names the chosen Map. `map create team.map.md --name Team --summary 'Team work'` creates an empty Space Map without overwriting an existing file. `map add team.map.md thread:x_0123456789abcdef01234567 --depth summary` adds an open address; `map add team.map.md --root-node-id n_0123456789abcdef01234567 --sha <commit> --position . --depth summary` adds a pinned root and position; use `--root <index>` for an existing root. `map add team.map.md --position . --checkout <identified-repo>` adds a local checkout without writing its device path to the Map; the checkout hint lives in `~/.ideaspaces/map-checkouts.json`, separate from hosted bindings, and is registered only after the Map edit succeeds. `map remove team.map.md <address>` drops an unambiguous address; for an index, pass `--if-match <file_sha>` from `map team.map.md --json`, so a concurrent edit cannot change which member the index means. Removing a root's last member keeps the root: roots are the Space, not a cache of members. To remove an empty root explicitly, use `map remove team.map.md --root-node-id <id>`; members must be removed first, and surviving root indices are reindexed. Use identity addresses rather than indices for repeatable edits. Concurrent CLI edits serialize under `<map-note>.lock`; a busy/stale lock refuses after four seconds, and `--if-match <sha>` refuses a moved base. If a writer crashed, check no writer is running before removing its empty lock directory with `rmdir <map-note>.lock`; never delete a live lock. The lock and hidden temporary file are transient siblings: stage the exact Map file, not `git add -A` during an edit. YAML nodes and comments survive edits, but long scalar formatting may change; the source's LF/CRLF convention is kept. Repeating an identical add reports `already_present` without rewriting the Map (and `--checkout` repairs a missing device hint); repeating an address or identity-position remove reports `already_absent` with `member_index: null` in JSON. If a Map edit succeeds but checkout registration fails, it reports `added_unregistered` with exit 1 (JSON still names the edited Map and SHA) and names the retry; no Map write is repeated. A failed registration on repeat reports `already_unregistered` with exit 1. Remove a position with `map remove team.map.md @n_0123456789abcdef01234567//notes/file.md` (root position ends in `//`) or `--root-node-id <id> --position <path>`; changed annotations on an existing pointer require an explicit edit or remove and re-add. A root identity is reused, and a pin mismatch is refused rather than silently changed. These file edits do not stage or commit. The reader shows each pinned root as `pinned` (checkout HEAD matches), `moved` (HEAD differs), or `unresolved` (no matching local checkout), and names other Maps in the same folder. Without one, it derives the local tree: JSON includes a portable `map` block only for a clean, exactly pinned root with stable identity that passes strict protocol validation. Dirty, unborn, unidentified, or invalid trees remain inspectable under `projection` without leaking their checkout path into a Map.
- **`fork` and `update`** validate before touching your disk and never overwrite your work; conflicts are reported.
- **`--json`** returns a `status`, the revision, and typed failure details. A partial write or commit exits non-zero.
- **`conversation send --local --runtime=claude`** runs Claude Code — the copy you installed and signed in to, unmodified. Usage bills to your own Claude plan (or to your API key with `--claude-auth=api-key` — without it, a stray `ANTHROPIC_API_KEY` in your shell is kept away from the turn; provider routing you configured yourself, such as Bedrock or Vertex, stays in force); the CLI never sees your credentials and reads only the session transcripts Claude Code writes under `~/.claude/projects/`. A turn streams the same events as a pi or hosted turn, so every client renders it the same way.

## Which local runtime

Both stream the same transcript; they differ in whose agent runs and how it is paid for.

| | `--runtime=pi` (default) | `--runtime=claude` |
|---|---|---|
| What runs | pi, bundled with the desktop or `pi` on your PATH | the Claude Code you installed and signed in to |
| Who pays | your API key for the provider you chose (`pi-login`) | your Claude plan, or your API key with `--claude-auth=api-key` |
| Models | any provider pi supports | Anthropic |
| Agent context | our extensions and skills, passed with `--ext` and `--skill` | whatever your Claude Code already carries — plugin, skills, `_agent/`, memory |
| Where sessions live | `<context>/.pi/sessions/`, inside the space | `~/.claude/projects/`, outside the space |
| Reasoning in the transcript | shown | not shown — Claude Code redacts it when run headless |
| Needs a Claude account | no | yes |
| Is it ready? | `pi-status` | `claude-status` — asks the binary (`claude --version`, `claude auth status`); never reads `~/.claude` |
| Models | `pi-models` — any provider pi supports | `claude-models` — Anthropic (on Claude Code ≥ 2.1.305: Opus/Sonnet/Haiku 5.5 and Fable 5.1, 1M context; older binaries retain their versioned roster) |
| Compaction | context skills (`context_cleanup`) | `conversation compact --local` and `--autocompact` (100k–1M tokens) |

Pick `claude` to continue a session you started in Claude Code; the same session id resumes it. Close it there first — one session should have one writer at a time. Pick `pi` for another provider, an API-key setup, or a machine without Claude Code.

## Configuration

| Path | What |
|---|---|
| `~/.ideaspaces/credentials.json` | API credentials |
| `~/.ideaspaces/spaces.json` | Known spaces and remotes |
| `~/.ideaspaces/cursors/` | Private per-reader local Thread acknowledgements |
| `~/.pi/agent/auth.json` | Local-agent model credentials |

`IS_API_KEY` overrides stored credentials. `IS_API_URL` points at another host. `IDEASPACES_GIT_BIN` (or `--git-bin <path>`) names an explicit Git executable for environments with no Git on PATH. `IDEASPACES_PI_EXTENSIONS` lists extension paths for direct `conversation send --local` (not `agent run` child authority). `CLAUDE_CONFIG_DIR` relocates the Claude Code sessions `--runtime=claude` reads, as it does for Claude Code itself.

## Contributing

Run `npm ci && npm run build` after any source, lockfile, or `build.mjs` change, and commit the rebuilt `bundle/ideaspaces.js` alongside the change. Rebuild after rebasing rather than hand-merging the generated file. `npm run check:package` compares the fresh bundle against the bytes committed at HEAD; CI and the release workflow run it before publishing.

Every server operation the CLI performs is listed in [`contract/api-calls.json`](contract/api-calls.json), generated from `src/auth/api.ts` by `npm run api:inventory`; a test fails when the source changes without it. `npm run check:api -- <openapi.json>` holds that inventory against a server's OpenAPI document and fails on any operation the server does not serve or marks deprecated. The document is an input — set `IDEASPACES_OPENAPI=<path>` to run the same check inside `npm test`.

## License

MIT
