Skip to content

feat(cli): Phase 0 control plane, list/status/open, bundling#132

Merged
simion merged 5 commits into
simion:mainfrom
MHohlios:feature/cli-phase0
Jul 23, 2026
Merged

feat(cli): Phase 0 control plane, list/status/open, bundling#132
simion merged 5 commits into
simion:mainfrom
MHohlios:feature/cli-phase0

Conversation

@MHohlios

@MHohlios MHohlios commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

This PR is Phase 0 of the termic CLI (design: #125): a termic command that talks to the running app over a local Unix socket.

What it does now: three read-only commands (list, status, open) plus all the machinery underneath them (socket, token auth, connect-or-launch, the bundled binary, and PATH install). It cannot create or change anything.

What it does not do yet: create or drive tasks. Those verbs land in later PRs.

This is a self-contained phase that merges on its own, following the repo's "one phase, one PR into main" shape (#62, #84, #113). Later phases follow as separate PRs with no ordering constraint between them, and every phase leaves the app's build green and behaviorally unchanged for anyone who has not opted in. Opened as a draft for now; ready to flip once reviewed.

Commands

Verb Phase What it does
list / ls 0 (this PR) List tasks: project, task, agent, work state, diff stat, branch. -q = ids only, --project filter
status <task> 0 (this PR) One task in depth: state, branch, path, sandbox, session count, dirty files
open [<task>] 0 (this PR) Raise the window and select a task (current-directory aware)
new <name> [-p …] 1 Create a task from the shell
wait <task> 1 Block until the agent is quiescent
archive <task> 1 Archive a task
project add / list / remove 1 Manage projects
send <task> -p … 2 Prompt a running agent
apply <task> 2 Send the task's diff to main
attach <task> 2 Attach a real TTY to the agent PTY
path / diff <task> 2 Print worktree path / show diff

Everything in Phase 0 is read-only or window-focus. Unimplemented verbs are simply absent (clap prints "unrecognized subcommand"), never stubbed or half-working. Global flags: --output-format text|json, --json, --no-launch.

Command name per build, and instances

The installed command name is build-aware, and the app permits one instance per data dir (the socket doubles as the lock). Because prod and beta share the release data dir, they are mutually exclusive by design: launching one while the other runs raises the running one and exits. Beta is long-term testing of prod, so you run one or the other, never both.

Build Command Data dir Instances
prod (release) termic termic one; shares the lock with beta
beta termic-beta termic (shared with prod) one; mutually exclusive with prod
dev (make dev) termic-dev termic_dev own lock; newest-wins on relaunch
e2e (test rig) uses the dev binary scratch TERMIC_DATA_DIR isolated, parallel-safe

The on-disk sidecar is always termic-cli; only the symlink name varies, so all three coexist on PATH.

Under the hood

  • Crates. A cargo workspace with termic-proto (wire types, NDJSON framing, protocol version, pinned exit codes) and termic-cli (the termic binary), which links only termic-proto, never the app's lib.rs.
  • Socket server on its own thread, never the IPC/main thread. <data_dir>/termic.sock (0600, sun_path guard, always binds). Unauthenticated surface is hello/raise only; every verb needs the "Enable CLI" setting (default off) AND the per-boot token from <data_dir>/cli-token (0600, in memory only, never in the app env that pty_spawn copies to children). getpeereid same-uid check per connection.
  • Webview RPC. Work state comes from the webview over a new typed correlation-id channel (cli-rpc://request + cli_rpc_result), not the debug /eval bridge, which is never armed or reused.
  • Sandbox boundary. The control socket is a privilege boundary: a caged agent that reaches it can escape. Enforcing profiles now emit the socket deny as the final network rule and the data-dir deny as the final filesystem rule, in both Enforce and EnforceFs. Verified behaviorally with real sandbox-exec.
  • Distribution. externalBin sidecar so the binary ships in the .app; enabling the CLI auto-installs it (no prompt) into ~/.local/bin, with a system-wide /usr/local/bin option (admin prompt on macOS). termic --version reports the app version, injected at build time, so the bundled CLI is always versioned with the app.

Off by default

There is a single Settings → General → "Enable CLI" toggle, off by default. While it is off, every command is refused and the app behaves exactly as it does today, so this is invisible to anyone who does not turn it on. (Exit codes and --json output are a stable, additive-only contract, so scripts can rely on them once shipped.)

Try it out

gh pr checkout 132     # or: git fetch <remote> && git checkout feature/cli-phase0
make dev               # launches the dev app on the termic_dev profile
                       # (first build after the Rust changes takes ~1-2 min)

In the running app: Settings → General → "Enable CLI" (toggle on). Enabling auto-installs the command as termic-dev into ~/.local/bin (no password). Then from any shell:

termic-dev list                    # project, task, agent, state, diff, branch
termic-dev status <project/task>   # one task in depth
termic-dev open <task>             # raises the window and selects that task
termic-dev --json list             # machine-readable

The -dev name is deliberate so it never clashes with a real termic install. If ~/.local/bin is not on your PATH, the Settings row says so, and make cli-dev is an alternative that builds and symlinks it for you. Toggling the setting off refuses every command immediately.

Testing

  • 226 Rust unit tests across the three crates: protocol round-trips and version-mismatch, exit-code and text/JSON output goldens, name and cwd resolution (worktree-first, longest-prefix, ambiguity), auth and gating, single-instance handshake, and behavioral sandbox-exec containment tests (in-cage connect refused, token read refused with ~/Library allow-listed through each extension layer, caged env carries no token). Plus the existing frontend suite.
  • CI runs cargo test --workspace --lib. A full release compile succeeds and stages the sidecar.
  • Manual smoke over the socket: enable/disable, list / status / open, and every error path (disabled, bad/missing token, no-launch, in-cage refusal).

Known / deferred

  • The prod/beta simultaneous-launch bind race is accepted and deferred to the tmux-style flock fix, per the design doc.
  • The behavioral sandbox-exec tests self-skip on the Ubuntu CI (macOS-only). A gated macOS CI job to run them for real is a follow-up.
  • macOS is the primary target. On Linux the install falls back to ~/.local/bin (no privilege prompt, since the /usr/local/bin elevation is macOS-only), and auto-launch is macOS-only; the sandbox boundary is macOS-only regardless (tasks are never caged on Linux).

@MHohlios
MHohlios force-pushed the feature/cli-phase0 branch 4 times, most recently from 04fc600 to 63bbb6a Compare July 23, 2026 02:44
MHohlios added 5 commits July 22, 2026 23:03
Introduce a cargo workspace (src-tauri was a single crate) with two
net-new members beside it:

- termic-proto: the CLI <-> app wire protocol. Request/response types,
  protocol version, NDJSON framing (compact-only, 1 MB line cap), and the
  pinned exit-code contract. Deserializers tolerate unknown fields so the
  public shapes evolve additively.
- termic-cli: the thin `termic` client. Verbs list/ls, status, open, with
  --output-format text|json and exit codes in --help. `list` shows project
  then task, matching how tasks are addressed (`status project/task`, the
  ambiguity errors). Links ONLY termic-proto, never the app's lib.rs.
  Socket discovery honors TERMIC_SOCKET; a debug build targets the
  termic_dev data dir and never auto-launches the release app. `list -q`
  skips the server work-state query + per-task git for TEXT output only
  (JSON keeps the full data); `open --project` requires a task rather than
  silently ignoring the filter. `termic --version` reports the app version,
  injected at build time via TERMIC_APP_VERSION (crate-version fallback for
  a bare cargo build). No em dashes in output or help.

Per docs/plans/cli.md (simion#125).
Serve the termic control plane from the app (the daemon):

- cli_server.rs: a Unix socket at <data_dir>/termic.sock (mode 0600,
  sun_path guard, always binds) on its OWN thread, never the IPC/main
  thread. The unauthenticated surface is hello + raise only; every verb
  needs the "Enable CLI" setting (default off) AND the per-boot token from
  <data_dir>/cli-token (0600, written only after the socket binds, in the
  server's memory only, never in the app process env). getpeereid same-uid
  check per connection. The accept loop survives transient errors instead
  of dying, and a malformed work-state entry is skipped rather than
  collapsing the whole map.
- Work-state comes from the webview over a new typed correlation-id channel
  (cli-rpc://request + cli_rpc_result, src/lib/cliRpc.ts): new hardened
  code that borrows the automation bridge's pattern but never reuses /eval
  or arms it, and runs in release builds. The aggregation precedence
  (working > attention > done > idle, inactive for zero tabs) is unit
  tested. cwd resolution is worktree-first then longest project-path
  prefix, ambiguity lists candidates.
- Enabling the CLI auto-installs the command (no prompt) into ~/.local/bin
  and reflects whether that is on the login PATH; the "Install system-wide"
  action (an optional upgrade to /usr/local/bin, shown prominently only
  when the auto-install did not land on PATH) uses an admin prompt. Command
  name is build-aware (termic / termic-dev / termic-beta) so dev/beta/prod
  coexist. The toggle reverts on a failed save.
- Single instance per data dir (release): the socket doubles as the lock,
  so a second launch on the same data dir raises the running one and
  exits. Debug is newest-wins.

The app builds and behaves identically for anyone who has not enabled the
CLI. Per docs/plans/cli.md (simion#125).
The control socket is a privilege boundary: a caged agent that reaches it
can spawn an uncaged task and escape. Close both halves as the FINAL rules
of the enforcing profiles (Enforce and EnforceFs, never Monitor), where
SBPL last-match-wins makes them beat any ancestor allow above:

- (deny network-outbound (remote unix-socket (path-literal <sock>))) as
  the last network rule, after the broad unix-socket allow, EnforceFs's
  blanket (allow network*), and the agy special-case outbound allow.
- (deny file-read*/file-write* (subpath <data_dir>)) as the last
  filesystem rules, so the CLI token, projects.json and tasks/ stay
  unreachable even when a broad ancestor (~, ~/Library) is allow-listed.

render_profile is refactored so render_profile_with is a pure function of
its inputs, letting tests inject a hostile allow-list through the same
seam the real layers flow through. Verified behaviorally with real
sandbox-exec: in-cage connect() refused, in-cage token read refused (with
~/Library allow-listed via the task and agent layers, a sibling file
proving the allow-list is live), and a caged spawn's env carries no token.
Each behavioral test writes its SBPL profile into its OWN temp dir, so a
security test can never race a sibling's profile file and false-alarm.
Monitor mode's deliberate exemption is documented in docs/sandbox.md.

Per docs/plans/cli.md (simion#125), Security + Testing.
- externalBin sidecar (binaries/termic-cli) so the CLI ships inside the
  .app and updates in lockstep with the app updater. src-tauri/build.rs
  builds + stages the sidecar for the current triple BEFORE
  tauri_build::build() runs, in the SAME profile as the app (a debug check
  does not trigger a release codegen of the CLI), and lipos the universal
  binary when both mac arch files exist.
- build.rs and scripts/build-cli.mjs inject TERMIC_APP_VERSION so
  `termic --version` matches the app the CLI ships in; make cli-dev does
  the same for the dev symlink.
- scripts/build-cli.mjs runs from beforeBuildCommand as the belt-and-
  braces for `tauri build`, staging every installed macOS arch plus the
  universal binary.
- CI runs cargo test --workspace --lib so the proto round-trips and cli
  goldens run alongside the app's tests.
- binaries/ is gitignored (arch-specific staging, never committed).

Per docs/plans/cli.md (simion#125), Distribution.
Record where the implementation refined the original design so Phase 1+
builds on an accurate spec: single-instance per data dir (the socket as
the lock, the new unauthenticated `raise` verb, debug newest-wins vs
release enforcement), build-aware install names (termic / termic-dev /
termic-beta) so dev/beta/prod coexist, auto-install on enable into
~/.local/bin with the system-wide upgrade, release-only auto-launch, and
build-time version injection so the bundled CLI is versioned with the app.
Updates the status line and the transport / launch / distribution /
landing sections.
@MHohlios
MHohlios force-pushed the feature/cli-phase0 branch from 63bbb6a to 0e13020 Compare July 23, 2026 03:03
@MHohlios
MHohlios marked this pull request as ready for review July 23, 2026 03:07

@simion simion left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The security model is the standout: 244-bit per-boot token in a create_new 0600 file, never exported to the process env (asserted by an absence test), peer same-uid check before any read, and SBPL denies placed as the final fs/network rules so last-match-wins can't invert them. Really appreciate that the containment claim is proven behaviorally with real sandbox-exec runs rather than a textual profile check, including a positive control that the allow-listed ancestor is otherwise readable. Resolution logic (worktree-vs-main-checkout precedence, composition members, shared-checkout ambiguity) and the NDJSON framing edge cases are all well covered, and the build.rs nested-cargo lock/env handling plus the universal-bundle belt-and-braces are thoughtful. Only nits, none blocking: per-connection threads are uncapped (bounded by same-uid + timeouts), and every app cargo check now recompiles the CLI (mitigated by profile-matching). Ship it.

@simion
simion merged commit e842c71 into simion:main Jul 23, 2026
2 checks passed
@simion

simion commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Will be shipped in the next release, probably this weekend. I manually re-test the entire app, and each new feature. before releasing :D

Thanks for the effort!

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