---
name: tm-cli-operations
description: Operate the tm / trusty-mpm CLI — set up and manage MCP servers, drive session lifecycle, and run health diagnostics
user-invocable: true
version: "1.0.0"
category: pm-reference
tags: [cli, mcp, sessions, services, doctor, auth, operations, pm-recommended]
effort: medium
---
# tm CLI Operations
How to drive the `tm` (trusty-mpm) binary itself: register and verify **MCP
servers**, manage the **session lifecycle**, and run **health diagnostics**.
This is distinct from `tm-tool-usage-guide`, which covers the PM's in-session
`mcp__trusty-mpm__* / mcp__trusty-memory__* / mcp__trusty-search__*` **tool**
families. This skill is about the `tm` **command-line binary** and the config it
owns.
**PM-delegation framing.** A PM under circuit-breaker discipline does not shell
out to run process-spawning or health commands (`tm mcp test`, `tm services`,
`tm doctor`) — it delegates execution to **local-ops** and reads back the
result. This skill tells you *what* to run and how to read it; local-ops (or you
at your own shell, outside a managed session) actually runs it. One-time config
edits (`tm mcp add/remove`, `tm auth set-token`) are setup, run once at your
shell.
---
## 1. MCP Server Management (headline)
### Where the config lives — the tm-owned config dir
Managed sessions launch `claude` under a **relocated** `CLAUDE_CONFIG_DIR`, not
your real `~/.claude`. `tm mcp` edits the top-level `mcpServers` map of that
dir's `.claude.json`:
- Default (daemon-managed): `~/.trusty-tools/trusty-mpm/claude-config/.claude.json`
- Standalone root: `<root>/claude-config/.claude.json` when you pass `--root`
(same precedence chain as `tm register`/`tm ls`:
`--root` > `TRUSTY_MPM_ROOT` env > `[standalone] root` in config > `~/.trusty-mpm`).
This map is **user scope**. There is deliberately **no `--scope` flag**:
`tm mcp` is inherently user scope. (Stock `claude mcp add` cannot target this
relocated dir, which is why `tm mcp` exists.)
**ANTI-PATTERN — do not hand-edit a project's `.mcp.json`.** When asked to
"register the X MCP server" with no scope qualifier, that almost always means
**user-wide** (available in every project/session), and the answer is always
`tm mcp add` — never editing a project's `.mcp.json` directly. A project
`.mcp.json` is **project scope**: visible only inside that one repo/worktree,
invisible to every other project and to the standalone `tm run` driver, and
(inside a fleet worktree) silently overwritten by `tm` on the next spawn
anyway. If a PM delegates "register the duetto-memory MCP" and an agent edits
`.mcp.json` in whatever worktree happens to be open, that is the wrong scope —
route it through `tm mcp add` instead.
**Where these servers actually reach (issue #2739, plus the custom-server follow-up):**
- The **standalone `tm run` driver** reads this user-scope map directly, so it
sees **every** server you add here.
- **Daemon-managed / fleet sessions** (`tm session new`) launch
`claude --setting-sources project,local`, which does NOT read the user tier.
Since the #2739 follow-up, this is bridged in TWO ways:
- **Native trusty MCP servers** — the allowlist `slack-mcp`, `telegram-mcp`,
`gworkspace-mcp`, `trusty-analyze` (plus the always-provisioned
`trusty-memory` / `trusty-search`, which have their own dedicated
injectors) — are bridged with `env` secrets routed to a workspace
`.env.local` (never the git-tracked `.mcp.json`).
- **Every other (custom, non-native) server you add** — stdio or remote
(http/sse) — is ALSO bridged now, with the same `env`-to-`.env.local`
routing for stdio servers. The one exception: a **remote server that
declares `headers`** (e.g. an `Authorization` bearer token) is **not**
bridged — there is no established out-of-band delivery channel for an
HTTP header secret into `.mcp.json` yet, so it fails closed rather than
leak the header into a git-tracked file. A remote server with a clean URL
and no `headers` bridges normally.
- **Project scope**: a project's own `<project>/.trusty-mpm/manifest.toml`
`[mcp.custom.<name>]` table (the same per-project override file
`[agents]`/`[skills]` already use) declares servers scoped to THAT
project's fleet sessions only, and — on a name collision — OVERRIDES the
user-scope registry entry of the same name. A `trusty-memory`/
`trusty-search`/native-allowlist name declared here is always REJECTED —
a project manifest can never override a reserved name (issue #3033).
- **Consent gate (issue #3033):** because a project-scope `[mcp.custom]`
entry ships with the cloned repo itself, it is honored ONLY after the
operator explicitly runs `tm project trust [--dir <path>]` for that
project — an untrusted project's `[mcp.custom]` table is skipped entirely
(a single warning names the project and hints the trust command). Trust
state lives in USER-scope config (`~/.trusty-tools/trusty-mpm/project-trust.json`),
never inside the repo, so a cloned repo can never self-trust. Revoke with
`tm project trust --revoke [--dir <path>]`; `tm project list`/`tm project
info` show an `[mcp-trusted]` marker / trust line for the current state.
User-scope registry entries (`tm mcp add`) are unaffected — you already
consented to those by registering them locally.
### The 3 auto-provisioned framework built-ins
Every managed session always gets these three, whether or not they appear in the
`mcpServers` map:
| `trusty-memory` | `trusty-memory serve --stdio` |
| `trusty-review` | `trusty-review serve --stdio` |
| `trusty-search` | `trusty-search serve` |
You never register these — they are built in. `tm mcp add` is for **additional**
servers on top of them.
### Adding a server — `tm mcp add`
stdio (default transport — a local subprocess speaking MCP over stdio):
```bash
# Options (-t/-e/--root) MUST precede the command token.
# A leading `--` stops flag parsing so hyphen-led args pass through.
tm mcp add my-server -e API_KEY=xxx -- npx -y @scope/some-mcp-server
tm mcp add pyserver -e LOG=debug -- python -m my_mcp_pkg --flag
```
- `-e KEY=VALUE` (repeatable) — environment for the subprocess. Splits on the
**first** `=` (so `-e B=x=y` → `B` = `x=y`).
- The first positional token after the options is the **command**; the rest are
subprocess args. `add` only **warns** (never blocks) when the command is not
on `PATH` — it may resolve later at session launch.
http / sse (a remote endpoint):
```bash
tm mcp add remote-api -t http -H "Authorization: Bearer $TOKEN" https://host/mcp
tm mcp add events -t sse -H "X-Api-Key: $KEY" https://host/sse
```
- `-H "Name: Value"` (repeatable) — HTTP headers. Splits on the **first** `:`,
value trimmed. Only valid for http/sse.
- `-e` and subprocess args are rejected for http/sse; `-H` is rejected for stdio.
### Propagation caveat — an already-running session needs a relaunch
`tm mcp add` writes the config immediately, but a **currently running**
session does not hot-reload it: the workspace `.mcp.json` (and its trust seed)
is only (re)built when a session **spawns or resumes**, never while the pane
is live. Concretely:
- **Standalone `tm run` driver** — sees the full user-scope map on its *next*
launch.
- **Fleet sessions (`tm session new`)** — only the native-allowlist servers
(`slack-mcp`, `telegram-mcp`, `gworkspace-mcp`, `trusty-analyze`, plus the
always-provisioned `trusty-memory`/`trusty-search`) get bridged into the
workspace `.mcp.json`, and only at spawn/resume. A non-native/custom server
(e.g. an internal `duetto-memory`) reaches the standalone driver only — it
is not yet bridged into fleet sessions at all (tracked by #2739).
So if a newly `tm mcp add`-ed server isn't showing up in `/mcp` inside a
session that was already running when you added it, the fix is to **relaunch
the session** (`tm session resume <id>`, or the in-pane `tm` relaunch hint) —
not to hand-edit that session's project `.mcp.json` as a workaround. That
edit both reintroduces the anti-pattern above and gets clobbered on the next
spawn anyway.
### How added servers flow into the trust-seed
You do **not** manage trust separately. On the next managed-session start, tm
computes the trust list as `BUILTIN_MANAGED_MCP_SERVERS ∪ (your mcpServers keys)`
and pre-seeds each workspace's `enabledMcpjsonServers` (via
`preseed_managed_trust`). Result: a `tm mcp add`-ed server is **auto-trusted** —
the session never shows the "New MCP servers found" approval dialog. No extra
bookkeeping.
### Inspecting — `tm mcp list` / `tm mcp get`
```bash
tm mcp list # table: NAME TYPE TARGET
tm mcp list --json # raw mcpServers map (automation)
tm mcp get my-server # one server: Type / Target / Scope
tm mcp get my-server --json # the raw entry
```
### Verifying — `tm mcp test` (the real handshake)
Registration only proves the config is well-formed. `tm mcp test` **spawns each
server and runs a real MCP handshake** so you (and CI) get a definitive
pass/fail. This is the MCP-layer check.
```bash
tm mcp test # bare sweep: every configured server ∪ the 3 built-ins
tm mcp test my-server # just one (config entry, else built-in fallback, else error)
tm mcp test --json # machine-readable results array (automation)
```
- **stdio** servers: full `initialize` → `tools/list` handshake; reports the
tool count on success. Bounded by a 5s probe timeout.
- **http/sse** servers: an HTTP reachability check.
- **Exit code: non-zero (1) if ANY tested server fails** — slots straight into
a CI gate, matching `tm services`' convention. A named server that isn't found
is a hard error.
**Interpreting failures** (the RESULT/DETAIL column, or `error` in `--json`):
| `spawn error: …` | The command couldn't be launched | Command not on `PATH`; fix the `-- <command>` or install it |
| `timeout` | No handshake within 5s | Server hung on startup, or waits on missing env/creds |
| `protocol error: …` | Launched but spoke bad/no MCP | Wrong command, non-MCP binary, or a version mismatch |
| `unreachable: …` | http/sse endpoint not reachable | Bad URL, server down, or auth header rejected |
### Migrating servers from a stock claude-code config
To bring servers you already use in stock Claude Code into the managed fleet,
read them from your existing config and re-register with `tm mcp add`:
- User-level source: `~/.claude.json` → its `mcpServers` map.
- Project-level source: a repo's `.mcp.json` → its `mcpServers` map.
For each entry, translate to a `tm mcp add`: `command` + `args` → the stdio form
(`-- <command> <args...>`), `env` → repeated `-e KEY=VALUE`, a `url` → the
`-t http|sse` form with `-H` headers. **Never echo secret env/header values into
logs or transcripts** — reference them via `$ENV_VAR`, and confirm the config
dir before writing. Verify the migration with `tm mcp test <name>`.
### Removing — `tm mcp remove`
```bash
tm mcp remove my-server # drops it from the map; no-op (not an error) if absent
```
---
## 2. Session Lifecycle
`tm session <verb>` has **two families** under one command group. A verb from one
family is not a valid target for the other's IDs.
**Local project sessions** (bound to the cwd, daemon-backed):
```bash
tm session start [--dir <path>] # new session in the project
tm session list [--dir <path>] # sessions for this project
tm session run <id|name> "<cmd>" # send a command to the pane
tm session output <id|name> [--lines N]# capture recent pane output
tm session clean [--dir <path>] # reap dead sessions
```
**Managed fleet sessions** (provisioned in isolated worktrees):
```bash
tm session new <repo> --task "<desc>" [--git-ref main] [--name-hint …] [--runtime claude-code|tcode]
tm session ls [--current | --source-id owner/repo] [--all] [--json]
tm session activity <id> # what a session is doing (no attach)
tm session send <id> "<text>" # inject text into the pane
tm session answer <id> "<answer>" # resolve a pending decision
tm session attach <id> # print the tmux attach command
```
**Shared verbs** (managed-aware — auto-detect managed vs local by id/name):
```bash
tm session resume <id|name> # re-spawn runtime / restore paused session
```
### The `tm ls` / bare `tm` picker (incl. `d<N>` delete)
On a real terminal, bare `tm ls` (and bare `tm`) opens the interactive
session picker — a numbered menu of live managed sessions. Actions:
- `<N>` — connect to session N.
- **`d<N>`** (or `d <N>`) — **delete** the session at slot N (#2304). A
confirm prompt guards it, and the delete is routed correctly for managed vs
local sessions.
- Piped / `--json` / non-TTY invocations degrade to a static, pipeable list
(never block on stdin). `tm ls --projects` (`-p`) shows the repo-alias /
project registry instead of sessions.
### Delete / teardown semantics — pick the right verb
| `stop` / `kill` | stopped | **preserved** | kept | **yes** |
| `decommission` | stopped | **removed** | tombstone kept | no (terminal) |
| `delete` | (guarded) | **never touched** | **hard-deleted** | no |
- `tm session decommission <id>` — the only op that **removes the workspace
directory**. Terminal. Keeps a tombstone for `ls` history.
- `tm session delete <id> [--force]` — hard-deletes the store **record** only
(never the workspace). **Fail-closed**: refuses a RUNNING session unless
`--force`. Use `decommission` first if you also want the disk reclaimed.
### Bulk cleanup
```bash
tm session prune-idle [--dry-run] [--json] # idle→stop, done→decommission (locked policy)
tm session prune-worktrees [--force] # remove orphaned .worktrees/ dirs (default dry-run)
```
`prune-worktrees` defaults to a safe preview (pass `--force` to act);
`prune`/`prune-idle` act immediately unless you pass `--dry-run` yourself.
`prune --include-active` is the only explicit override to also touch RUNNING
sessions; `prune-idle`'s idle/done verdict policy inherently leaves
working/blocked sessions alone, and `prune-worktrees` only ever removes
directories that have no active session at all.
---
## 3. Health & Diagnostics
Three complementary layers — use the right one:
| `tm doctor` | Full trusty-mpm stack (instructions, agents, skills, memory, search) — needs a reachable daemon |
| `tm validate [--path <dir>] [--repair]` | A workspace's deployed `.claude/{agents,skills}` + `settings.json` vs the bundled roster — **standalone, no daemon** |
| `tm services status <name> [--json]` | A service daemon's **HTTP health** (is trusty-search up, on what port, healthy) |
| `tm mcp test [<name>]` | The **MCP protocol layer** — does each server actually spawn and speak MCP |
Key distinction: **`tm services` checks HTTP liveness; `tm mcp test` checks the
MCP handshake.** A daemon can be HTTP-healthy yet fail its MCP handshake (or vice
versa) — test the layer you actually care about.
```bash
tm doctor # full diagnostic report
tm validate --repair # re-run the deploy pipeline to close gaps; non-zero exit if gaps remain
tm services list [--json] # every declared service + status (exit 0)
tm services status trusty-search # 0=running, 1=down, 2=unknown service
tm services health trusty-search # probes /health: prints OK / FAIL
```
`tm services` exit codes are stable and scriptable: `0` ok/running, `1`
down/unhealthy, `2` unknown service.
---
## 4. Managed Auth (`CLAUDE_CODE_OAUTH_TOKEN`)
Because managed sessions run under a relocated `CLAUDE_CONFIG_DIR`, a `/login`
inside a managed session can diverge from your default-dir login (the macOS
Keychain entry is keyed by a hash of the config dir) — producing a "login
successful" → "not logged in" loop (#2246). `tm auth` manages a tm-owned token
that bypasses the Keychain entirely.
```bash
tm auth status # presence of stored token + env vars + on-disk creds — NEVER the value
tm auth clear-token # remove it (roll back to ambient auth)
```
The token is written 0600 to `~/.trusty-tools/trusty-mpm/claude-code-oauth.token`
and injected by the daemon into managed sessions. `tm auth status` never prints
the secret — safe to include in a `tm doctor`-style report.
---
## 5. Other Operator Commands
```bash
tm install [--force] # deploy bundled framework artifacts to ~/.trusty-mpm/framework/
tm register <alias> <url> [--force]# map a repo alias → URL (no clone)
tm load <alias> # clone/refresh + deploy managed config
tm run <alias> [--task …] # launch an interactive claude session for an alias
tm path <alias> # print the stable repo path (IDE-attach / cd)
tm update [<alias>] # git pull --ff-only + re-deploy config (all loaded aliases if omitted)
tm rm <alias> # deregister + delete the alias's project dir (shared config untouched)
```
🔴 **`tm start` and `tm restart` REFUSE when launchd owns the daemon** (issue
#4230). On a host with a registered daemon launchd unit they exit with an error
naming the right command instead of spawning a second, unsupervised daemon that
would seize the port and keep serving a stale binary. Use launchd there:
```bash
tm doctor # read the exact recipe from `daemon_orphan` / the refusal message
tm doctor # after restarting: `daemon_orphan` must be OK
```
Take the label from `tm doctor`'s own output rather than assuming
`com.trusty.mpm` — the refusal message and every `daemon_orphan` remediation name
the unit that is actually registered on this host, which is not always the
canonical label. The recipe has the shape
`launchctl kickstart -k gui/$(id -u)/<label>`.
`tm stop` still works, but launchd will NOT bring the daemon back on its own
(`KeepAlive.SuccessfulExit=false`) — use the `kickstart` above. To run an
unsupervised daemon deliberately, `tm daemon --force`.
`--root <path>` on the alias commands (and `tm mcp`) switches to a standalone
managed root via the precedence chain in §1.
---
## When to load this skill
Load `/tm-cli-operations` when the task is to **set up or verify an MCP server**
for the fleet, **drive session lifecycle** beyond the MCP session tools, or
**diagnose the tm stack** at the CLI. For the PM's in-session MCP *tools*, see
`tm-tool-usage-guide`; for the delegation model, `tm-delegation-patterns`.