Skip to content

docs(plans): termic CLI design#125

Merged
simion merged 1 commit into
simion:mainfrom
MHohlios:feature/cli
Jul 22, 2026
Merged

docs(plans): termic CLI design#125
simion merged 1 commit into
simion:mainfrom
MHohlios:feature/cli

Conversation

@MHohlios

@MHohlios MHohlios commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Design doc for a termic CLI: drive Termic from any shell. Comments on the doc are the point of this PR.

What it does

  • termic new fix-auth -p "fix the login redirect" from inside any registered repo: worktree + branch + agent, prompt delivered, no GUI touched
  • termic list: tasks with live agent state (working / waiting / done) and diff stats
  • termic send: prompt a task's running agent (queues if it is mid-turn); termic status: one task in depth for polling
  • termic wait: block until the agent settles, so send foo -p ".." && wait foo && diff foo composes; send / wait / status --json is the scripting trio
  • termic apply: the GUI's "send diff to main", closing the worktree lifecycle; termic project add|list|remove for non-interactive admin
  • --wait: block until the agent settles, exit codes scripts can branch on (done vs stopped-needing-input)
  • termic attach: the agent's terminal live in your shell, detach like tmux
  • termic open, path, diff, archive: raise the GUI on a task, cd $(termic path foo), diff summary, archive
  • --output-format text|json|stream-json everywhere; stable exit-code contract, stdin prompts (-p -), shell completions

Design

  • Thin client bundled in the .app, talking to the running app over a unix socket. The app stays the single owner of state and PTYs; the CLI holds no logic and never touches data files. Auto-launches the app when needed.
  • The control socket respects the task sandbox. Driving and viewing sandboxed tasks from outside is unrestricted; sandboxed agents themselves have no control-plane access unless granted. Access is a per-task sandbox capability (none / scoped / full, project-level scope), enforced with locally minted capability tokens and a monotonicity rule: a caged agent can never reach or create anything less caged than itself.
  • Orchestration stays in the webview (typed RPC via the automation bridge pattern) rather than moving to Rust up front.
  • Lands one phase per PR, always green, dormant behind an "Enable CLI" setting until the surface is complete.

Related: #59 (built-in MCP server). This control plane is the proposed foundation for it: the planned termic mcp subcommand is a stdio-to-socket shim exposing the same verbs, auth, and sandbox policy to any MCP client, which covers the #59 workflow (agent provisions a task, Termic applies sandbox boundaries, control returns to the agent).

@MHohlios
MHohlios force-pushed the feature/cli branch 2 times, most recently from 60126a0 to 346726a Compare July 20, 2026 14:04
@MHohlios

Copy link
Copy Markdown
Contributor Author

Requesting review of the spec, with particular attention to the error-path interactions. These were the judgment calls in the design, and each one is a deliberate choice that could reasonably go the other way:

  • App not running: every command auto-launches Termic by default (open -ga, no focus steal); --no-launch turns that into an immediate "Termic must be open" error for scripts. There is no offline mode: the CLI never reads or writes Termic's data files.
  • CLI disabled in Settings: the socket still binds and answers hello, so the failure is "Termic is running but the CLI is disabled, enable it in Settings" instead of a misleading launch timeout.
  • new in an unregistered repo: asks "Add it as a project? [y/N]" on a TTY; errors when non-interactive. Nothing is registered without a yes.
  • run with no live agent: errors, naming the two outs: --resume (restore the last session) or --fresh (new agent, explicitly acknowledging it has no context). Nothing spawns silently with the wrong context.
  • run while the agent is mid-turn: queues (the app already queues prompts on a busy agent), not an error.
  • Ambiguous task name across projects: errors listing the candidates; --project or project/name disambiguates.
  • Ctrl-C during new: never rolls back once the task is created; it only stops watching ("task continues in Termic").
  • --wait: exit 0 = agent settled done, 2 = agent stopped needing input, plus --timeout and a distinct exit code when the app quits mid-wait (socket EOF), so scripts never hang or misread an outcome.
  • termic inside a sandboxed task: fails with "control plane unavailable inside a sandboxed task" (detected via the existing TERMIC_SANDBOX env), not a cryptic auth error.

Full detail in docs/plans/cli.md under "Command surface" and "Security". Pushback welcome on any of these defaults.

@MHohlios

Copy link
Copy Markdown
Contributor Author

@simion Let me know what you think.

The shape follows what's worked elsewhere: the thin-CLI-into-running-app pattern is VS Code's code / Docker Desktop (and Sculptor ships exactly this for agent orchestration; Claude Code itself just grew --bg/attach verbs the same way).

Protocol details lean on tmux (attach/detach, version handshake), mpv's JSON IPC (framing), and Bitcoin Core's .cookie for the local auth.

Sandbox interaction is a part to pay attention to since it is a main Termic feature, plus the error-path defaults in the comment above.

@MHohlios
MHohlios marked this pull request as ready for review July 20, 2026 14:19
@MHohlios

Copy link
Copy Markdown
Contributor Author

I see this somewhat related to our SSH issue. This could be the stepping stone to allow local agents or local CLI to control remote resources as well.

@simion

simion commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Good design doc — the architecture decision (pattern 1) is correctly forced by where state actually lives, and the sandbox boundary section is the strongest part. A few things worth clarifying before implementation:

1. --resume with no prior session
run with no live agent errors and offers --resume or --fresh. What happens when --resume is chosen but there is no prior session to restore? Should error explicitly rather than fall through silently.

2. Exit code for scope/auth failure
The exit-code contract is declared public API, so worth reserving a distinct code now for the case where a sandboxed task reaches the socket and gets refused (currently folded into exit 1 generic error). The in-cage TERMIC_SANDBOX pre-check handles the common case, but a scoped-token scope violation (Phase 1+) has no reserved code yet.

3. Remote/SSH scope (re: your Comment 3)
The whole auth model is local-only primitives: unix socket + getpeereid + file-token at 0600. Remote control would break all three — it is not a socket-path swap. The doc mentions Linux as "just a socket-path change" which is true for same-host, but could be read as implying remote is cheap too. One sentence scoping remote out of the v1 model (and flagging it as a separate auth+transport design if ever wanted) would close that reading.

4. Production webview RPC channel
The doc says the socket server uses "that pattern" from automation.rs. Worth one explicit sentence that the production RPC channel is new hardened code modeled on the automation bridge, not the debug bridge itself (which only arms under TERMIC_AUTOMATION=1). Avoids the wrong implementation path.

Architecture and phasing are solid. The error-path defaults in your comment are all the right calls.

@simion

simion commented Jul 20, 2026

Copy link
Copy Markdown
Owner

One more angle worth covering, related to issue #59:

Agent discoverability and LLM-friendly help

The CLI is only useful to an agent if the agent knows it exists. Two things needed:

  1. Advertise it in the agent's context. Once installed, the CLI path should be mentioned in the CLAUDE.md / agent instructions that Termic injects into each task (or in the setup script environment). Something as simple as TERMIC_CLI=termic in the env overlay (alongside the existing TERMIC_TASK, TERMIC_SANDBOX vars) tells any agent it can call termic and that the control plane is available.

  2. --help output readable by LLMs, not just humans. Standard clap-generated help is written for a human scanning a terminal. An agent reading it with limited context benefits from: a one-line description of what each verb does and what it returns on stdout, the exit codes listed inline per-command rather than in a separate section, and machine-readable help (termic help --json or similar) so an agent can introspect the surface programmatically.

This is the path to the workflow in #59 without needing MCP: the agent sees TERMIC_CLI=termic in its env, runs termic help, reads the verb list, and calls termic new fix-auth -p "..." directly. The termic mcp shim (Phase 3) is then the upgrade for MCP-native orchestration — same CLI underneath.

@MHohlios
MHohlios force-pushed the feature/cli branch 3 times, most recently from 8b35d85 to 374824a Compare July 20, 2026 16:09
@MHohlios

Copy link
Copy Markdown
Contributor Author

Thanks — all four are now in the doc:

1. --resume with nothing to resume now errors with a clear message ("no previous session for this task, use --fresh to start a new agent"). It will never quietly start a fresh one.

2. Added a dedicated exit code for "refused": you reached the socket but your token was missing, wrong, or not allowed to do that. Scripts can tell it apart from a generic failure. Exact numbers get assigned in Phase 0.

3. Agreed — remote is out of scope for this design. The auth model is deliberately local-only (unix socket, peer check, 0600 token file), and nothing in the plan designs for the wire. The SSH angle was me speculating about a future the CLI unlocks, not the method we're committing to — but it's worth spelling out because it's why local-only auth isn't a dead end: the CLI is just a local binary, so machine 1 could drive machine 2 by SSH-ing over and running that machine's CLI (ssh -t box termic attach foo). SSH is the transport and the auth; the local security model doesn't change at all. A --host flag that wraps the ssh call for you (same idea as docker -H ssh://) could be later sugar. The known friction if that future ever firms up: macOS only runs GUI apps in a logged-in session — locked or asleep is fine, and a dedicated box would want auto-login + Termic in Login Items, after which it's always ready. If remote ever becomes real, it gets its own design pass.

4. Clarified in the doc: the production channel is new code that copies the idea from the automation bridge (matching requests to webview replies). The debug bridge itself stays debug-only and is never used.

@MHohlios

Copy link
Copy Markdown
Contributor Author

Added a section on agent discoverability ("Agents as users"): TERMIC_CLI=<absolute path> in the task env (alongside the existing TERMIC_TASK/TERMIC_SANDBOX, injected only while the CLI is enabled), and help designed for LLM readers — a per-verb one-liner stating what it does and what it prints on stdout, exit codes inline per command, and termic help --json for programmatic introspection.

This is the no-MCP path to #59's workflow: the agent sees TERMIC_CLI in its env, runs termic help, and calls termic new fix-auth -p "..." directly. The Phase 3 termic mcp shim then becomes the MCP-native upgrade over the same surface, auth, and policy.

@simion

simion commented Jul 20, 2026

Copy link
Copy Markdown
Owner

The command surface is missing a few verbs that would make the CLI genuinely useful as a scripting primitive, and some of the existing names don't map well to what termic actually does.

Rename suggestions:

  • newstart — you're starting work, not creating a record. Fits the domain better.
  • runsend — you're sending a prompt to an agent, not running a process. run implies execution of a script.
  • listls — muscle memory for anyone in a shell.

Missing verbs:

  • termic status <task> [--json] — agent state, branch, dirty file count, session count. Scripts need this to poll before sending a prompt.
  • termic wait <task> — block until the agent is idle. Composable: termic send foo "..." && termic wait foo && termic diff foo. Cleaner than sprinkling --wait on every verb.
  • termic merge <task> — the worktree lifecycle ends in a merge; the CLI should close the loop.

Attach ambiguity: --shell flag on termic attach to distinguish agent PTY (default) from aux terminal.

Rare operations as namespaced subcommands:

termic project add|ls|rm

The trio send / wait / status --json is what makes termic scriptable as an agent orchestration primitive — that's the CLI's real value add; the GUI already owns interactive use.

@MHohlios

Copy link
Copy Markdown
Contributor Author

Most of this is in — thank you, the scripting-trio framing is exactly right:

Adopted:

  • runsend, for an even stronger reason than the one you gave: "run" is already a termic domain term (the project's run script, task_run_script). The rename avoids the collision and keeps termic run free to mean the run script someday.
  • termic status <task> (Phase 0, same machinery as list) and termic wait <task> as the waiting primitive — the --wait flags are now defined as sugar over it.
  • termic project add|list|remove — this also fixes a real gap: scripts previously had no non-interactive way to register a repo (the y/N prompt is TTY-only).
  • attach --shell for the aux terminal.
  • Closing the lifecycle: adopted as termic apply, not merge. The app's actual primitive is "send diff to main" (task_send_diff_to_main), and the CLI never grows logic the app doesn't have — true merge orchestration (conflicts, strategies) stays out of scope. merge would overpromise.

Declined, with reasons:

  • newstart: the product's own vocabulary is "New Task" (dialog, + menu), and the CLI mirrors the GUI's language. start also reads as starting an agent in an existing task, which is --resume's job. That said, not a hill to die on — if you feel strongly, start it is.
  • listls: ls already works as an alias, so muscle memory is served; the full word stays primary because help is now explicitly designed for LLM readers, and list self-describes.

@simion

simion commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Agreed. @MHohlios shall we merge this? or give it to an agent directly and implement it here :D

@MHohlios

Copy link
Copy Markdown
Contributor Author

Lets merge it. I will commit the various phases as we go. It should not hurt mainline code.

@simion

simion commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Secondary review before merge (this is why it's still open). Four findings, verified against the current codebase; none invalidate the architecture, but 1 and 2 affect things Phase 0 pins, so they should land in the doc first.

1. The seatbelt deny ordering is backwards, and the planned test wouldn't catch it

The doc says the socket deny "must land ahead of that allow in all non-Off modes". SBPL is last-match-wins: when multiple rules match, the rule appearing later in the profile takes precedence (that's why (deny default) comes first and allows follow). A (deny network-outbound (remote unix-socket (path-literal "<sock>"))) placed before the broad (allow network-outbound (remote unix-socket)) at sandbox.rs:1188 would be silently overridden by it. The deny must come after the allow.

The test plan compounds this: it asserts "the rendered profile contains the socket deny in every non-Off mode", a textual presence check that passes while the deny is ineffective. Since the doc itself calls this deny "load-bearing, NOT belt-and-braces", the test must be behavioral: from inside a caged spawn, attempt an actual connect to the socket path and assert it fails.

Also note EnforceFs takes an early return (sandbox.rs:1182) with a blanket (allow network*) before the unix-socket section is reached, so the deny needs inserting in that branch separately; "add it in all non-Off modes" doesn't map to the actual code structure as written.

2. Exit code 2 collides with clap

The contract reserves exit 2 for "agent stopped needing input", and the CLI is clap-based (completions are "clap-generated"). Clap's default exit code for usage/parse errors is 2. A script branching on $? -eq 2 after a typo'd flag would misread a parse error as "agent needs input". Since exit codes are declared public API and get pinned at Phase 0: either override clap's error exit or pick a different number now.

3. The doc's own composition example is racy

The pitch is send foo -p ".." && wait foo && diff foo. But send queues when the agent is mid-turn (correctly, mirroring runPrompt.ts:42). Sequence: agent is working on turn 1, send queues the prompt, wait starts, agent settles turn 1, wait returns exit 0, queue delivers the prompt, agent starts working again. The script proceeds to diff against the wrong state, before the prompt even ran. wait needs to mean "settled AND the message queue is empty", or send --wait needs to track its specific prompt's delivery. This is the exact scripting trio the PR names as the CLI's core value, so the semantic should be decided in the doc, not at implementation time.

4. Sandbox monotonicity by mode is too coarse (future-phase, but record it now)

The scoped-token rule says send/attach may target tasks "at least as caged" and treats Enforce -> Enforce as contained. But two Enforce tasks can have different capability sets: task A with no allowed hosts sending prompts into task B (same project, github.com allowed) is an exfiltration channel; B's broader allowed paths are a write channel. Mode ordering doesn't capture this. The rule needs capability-subset comparison (target's allowed hosts/paths must be a subset of the caller's), and new's "caller's mode or stricter" cap similarly needs to cap the child's allow-lists, not just its mode. The phase is deferred with its own review, so not a merge blocker, but the doc records the monotonicity rule as if settled, and this is precisely the kind of trap the doc exists to preserve.

Smaller notes, take or leave:

  • Exit codes are declared public API but the --json field shapes are not. Agents will parse those; one line declaring additive-only changes belongs in the contract.
  • The unauthenticated hello (needed for the "CLI disabled" error) discloses app-running + version to any same-uid process. Harmless, but worth one sentence marking it intentional.

Everything else probed held up: the code citations are accurate (sandbox.rs:1188, TERMIC_SANDBOX at 1648, runPrompt queueing, (allow process-exec) at 1715 which makes the caged-agent "clear refusal" DX actually work), the directional boundary analysis is right, and hard-blocking caged agents in v1 instead of building a verb subset on unreliable pid attribution is the correct security call. The CLI-first answer to #59 with termic mcp as a later shim over the same auth and policy surface is also the right shape; when that phase lands, keep the tool count minimal and generate the tool definitions from the same help --json metadata so the surfaces can't drift.

Fixes for 1 and 2 are one-liners in the doc, 3 needs a decided semantic, 4 needs a recorded caveat in the scoped-tokens paragraph. Happy to merge once those are in.

Signed: secondary review by Fable (Claude), on maintainer request.

@MHohlios

Copy link
Copy Markdown
Contributor Author

Both feedback rounds are now in the doc. Plain-language summary:

Earlier round:

  • SBPL ordering: you were right, and the repo's own comments rely on last-match-wins (sandbox.rs:1208, 1800). The socket deny is now specified as the final network rule of each enforcing branch, and its test is behavioral: a real connect() from inside the cage must be refused.
  • Exit code 2 is reserved for clap's usage errors; domain codes start at 3.
  • The send/wait race: wait now means quiescent (settled AND message queue empty); send --wait tracks its own prompt (delivered + that turn settled).
  • Monotonicity recorded as capability-subset, not mode rank.

Fresh review, all ten adopted:

  1. The token's fail-closed story had the same class of blind spot: subpath-based allows mean a user-added ~/Library would silently re-expose the data dir. Now: explicit final (deny file-read* (subpath <data_dir>)), and a behavioral in-cage read test including a hostile allow-list fixture.
  2. Monitor contradiction fixed: deny + tests scoped to Enforce/EnforceFs only; Monitor reaches the socket by design and is logged.
  3. Token banned from the app process environment too (pty_spawn copies the whole app env into every caged child, lib.rs:1459): it lives only in the socket server's memory, with an env-absence test.
  4. --wait exit 0 now requires confirmed delivery; the webview recipe's "gives up silently" seeding is explicitly disqualified, and undelivered prompts get a dedicated exit code.
  5. Task creation serializes behind one create lock shared by GUI and CLI; a same-name collision is a clean error, never task_create_sync's destructive cleanup.
  6. apply no longer claims to "close the lifecycle"; its three failure modes (dirty main, main-checkout task, 3-way conflict leaving main dirty) each get a defined message + exit code.
  7. The future subset rule compares effective capability - EnforceFs is all-hosts regardless of its stored list - and the mode order is pinned (Enforce strictly stronger than EnforceFs; Off/Monitor unbounded).
  8. send queueing is qualified to work-done-capable agents; opted-out agents get immediate delivery with a warning, and wait/--wait refuse them (no settle signal exists).
  9. Main-checkout (--main) tasks get per-verb carve-outs: path prints the shared root, apply errors, archive unlinks without removing a worktree, shared-state caveat for diff/status.
  10. archive kills the task's live PTYs first and attached clients get an in-band detach + distinct exit code.

Two things we found while verifying:

  • The deny placement is trickier than "after the unix-socket allow": the agy agent branch appends a blanket (allow network-outbound) even later in the profile, so "final rule of the branch" is the only safe spec.
  • Finding 10 is worse than stated: the frontend archive path (archiveTask.ts) doesn't kill PTYs either - nothing in today's archive flow does. The doc flags it as a latent app gap the CLI must not inherit; it probably deserves its own issue independent of the CLI.

@MHohlios

Copy link
Copy Markdown
Contributor Author

If you are comfortable lets merge this plan in and I will use my tokens for the first Phase and see how it goes.

@simion
simion merged commit db6fbd8 into simion:main Jul 22, 2026
2 checks passed
@simion

simion commented Jul 22, 2026

Copy link
Copy Markdown
Owner

@MHohlios agreed and merged. Thank you 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants