A terminal client for the OpenRouter.ai API and the Liquid Console inference API.
orcli is built around one substantial feature: an interactive, full-screen chat
interface in the manner of Codex, ChatGPT, Claude, and Perplexity. Around it
sits a thin surface of non-interactive commands (version, help, and the flag
parsing for --bootstrap and --mouse).
The project is pre-release. PORTVERSION is 0.0.0-dev, and no release has
been scoped.
The project is pre-release, but no longer at the beginning: cmd/orcli builds
a binary, and the interface, tools, configuration, and persistence are written
and tested alongside the API client.
Implemented:
| Package | Contents |
|---|---|
cmd/orcli |
main package: flags, trust gate, wiring |
internal/apiary |
Apiary client |
internal/clipboard |
clipboard access |
internal/cloudflare |
Cloudflare client |
internal/config |
JSON configuration, colour writer, trusted directories, MCP and keychain settings |
internal/github |
GitHub client |
internal/groq |
Groq client |
internal/liquid |
Liquid Console client |
internal/keychain |
macOS Keychain access |
internal/mcp |
Model Context Protocol client (stdio and Streamable HTTP transports) |
internal/notion |
Notion client |
internal/openrouter |
HTTP client, SSE stream parser, wire types |
internal/saved |
SQLite session store, autosave naming |
internal/tools |
tool execution: filesystem, git, shell |
internal/tui |
the interface: rendering, input, session, panes |
Tested:
| Area | Coverage |
|---|---|
| Stream parser | marker required, text kept before a cut, fragments joined on the wire index, counts read in every shape the endpoint uses |
| Request path | the request and its headers, the body reaching the parser unconsumed, a refused request quoted and bounded and redacted, tool calls and usage end to end, cancellation |
Not yet written:
| Package | Contents |
|---|---|
internal/bootstrap |
--bootstrap documents and symlink resolution |
internal/complete |
Tab completion engine |
Set provider to liquid to use the Liquid Console inference API. The API key
uses the existing api_key configuration field and must stay in the mode 0600
configuration file. Keys are never read from the environment.
{
"provider": "liquid",
"api_key": "REPLACE_LOCALLY_WITH_YOUR_LIQUID_KEY"
}The same example is in orcli.json.example.
According to the Liquid Console API reference,
Liquid requests use https://api.liquid.ai/openai/v1/chat/completions and
GET /openai/v1/models, with bearer authentication. /models lists the live
model IDs. The documented catalog does not include prices or context limits, so
prices are shown as unavailable and /freemodels does not treat unknown prices
as free. Liquid does not document a key usage endpoint. The transport preserves
usage values reported in chat streams, though the current interface does not
render those values.
The API reference describes chat completions as OpenAI-compatible and supports streaming and tool request parameters. The client keeps tool execution inside orcli's existing dispatch and permission flow. No live API call has been made.
This summary is a short orientation, written from the current source tree. It is not a substitute for reading the code.
internal/bootstrap and internal/complete are not yet in the tree; they
are the design as recorded, not a description of code that exists.
cmd/orcli/ main package: flags, trust gate, wiring
internal/apiary/ Apiary client
internal/bootstrap/ --bootstrap documents (.md, .json, .db) and symlink resolution
internal/clipboard/ clipboard access
internal/cloudflare/ Cloudflare client
internal/complete/ Tab completion engine
internal/config/ JSON configuration, colour writer, trusted directories
internal/github/ GitHub client
internal/groq/ Groq client
internal/liquid/ Liquid Console client
internal/keychain/ macOS Keychain access
internal/mcp/ Model Context Protocol client (stdio and Streamable HTTP)
internal/notion/ Notion client
internal/openrouter/ HTTP client, SSE stream parser, wire types, cost ledger input
internal/saved/ SQLite session store, autosave naming
internal/tools/ tool execution: filesystem, git, shell
internal/tui/ the interface: rendering, input, session, panes
internal/ is the module boundary, and the dependency direction is one-way and
deliberate: cmd/orcli is the only package that imports both tui and
tools, and wires them together at runtime rather than letting either import
the other directly.
cmd/orcli → apiary, cloudflare, config, github, groq, liquid, keychain,
mcp, notion, openrouter, saved, tools, tui
openrouter → (stdlib)
tools imports openrouter only for the wire types. It never runs a request;
tui runs the requests and the tools. That is why the model tools cannot reach
the interface that drew them.
The interface uses github.com/gdamore/tcell/v2, but narrowly: tcell handles
terminal initialization, terminfo capability lookup, and decoding key, mouse,
and paste input events. The actual screen painting is still direct raw writes
to stdout, driven by the same copyable-text requirement that needs control of
mouse reporting that higher-level libraries take by default; tcell's own
buffer-based Draw/Show path is never used by the active rendering code.
(internal/tui also contains a fully tview/tcell-rendered code path, but it
has no callers and is not reached at runtime.)
Frame is the whole interface at one moment. Its load-bearing contract is that
every row leaves the renderer as plain text, with the bytes a terminal would act
on removed, and colour travels beside the rows as spans rather than inside
them. Rows can therefore be measured, folded, scrolled, searched, and copied
without knowing about colour at all.
The frame is vertically partitioned, and when space runs short the header yields before the prompt does, giving up rules first, then blanks, then the title. A rule is never drawn without the blanks on both sides of it. Widths are counted in display columns, not bytes or runes, so a box-drawing rule is not reported three times too wide.
The line editor reads blocks rather than bytes, so a whole mouse report is recognised before it can be read as a key. Key sequences are consumed from their shape rather than from a table of lengths. Above the prompt, pasted rows, queued rows, a notice, a hint row, a confirm box, and the prompt are each budgeted against the rows left after the prompt is accounted for, so none of them can push the prompt off the bottom of the screen.
Retention is decided in one place: the record function checks an ephemeral flag and returns. Everything that promises not to record is implemented by setting that flag, because two places could disagree and the disagreement would be a session that appeared to record nothing while keeping everything.
The bootstrap instructions are a system turn, and a reset keeps them. A tool-using turn is committed once, at the end, and only on a clean exit, so a turn the reader stopped leaves no tool result behind for the model to be told about as though it had asked for one and been answered.
From the package documentation: a call always produces a result. An unknown name, arguments that are not a JSON object, a body that returned an error, and a body that panicked are all reported as a result carrying that error, because a call producing no result at all leaves the turn waiting for something that never arrives, and a turn that waits is reported by a reader as a hang rather than as a fault.
Filesystem containment is an open descriptor, not a prefix check. A prefix check on a cleaned path is defeated by exactly the symlink that a string comparison cannot see. Git and shell use subprocesses, so they compare the resolved path against the resolved working directory instead. Neither ever reaches a shell: arguments go to the process as an array.
Notion uses a workspace integration token in the notion.api_key configuration
member. The model can search and read pages, work with blocks, pages, databases,
data sources, comments, users and file-upload metadata through the public REST
API. notion_api exposes the remaining documented JSON operations by name.
Binary file transfer and connector-hosted features such as mail, agent sessions,
and AI memory are not part of the standalone REST client.
Permission is three levels in increasing precedence: an allowlist bounds what may
be proposed, an approval mode settles every call a file rule does not, and a file
rule under OPENROUTER_TOOLS settles a call whatever the mode says. The nearest
enclosing rule decides, not the union.
Tools are offered under one condition, that the conversation is recording. A tool acts on the reader's behalf, and a mode whose promise is that nothing is recorded cannot hand the model a hand that acts.
Standard library only. The terminating [DONE] marker is required rather than
assumed, because a stream that ends without it was cut short and must not be
presented as a complete reply. Failures are reported through the callback rather
than as a return value, so the text that arrived before a failure is kept. Tool
call fragments are reassembled by the transport and joined on the wire index
rather than arrival order, since the index is the only field every fragment
carries. Usage and cost are pointers, because a reported zero and an absent value
are different.
Accounting is decoded apart from the reply text. The endpoint sends counts in shapes this client cannot always read, and a figure it cannot parse must not cost the words beside it: a session with no cost for one response has an inexact total, which is a thing the ledger can say, while a lost reply is not.
JSON, because the standard library has no YAML parser and a configuration file is
not a place where a dependency is worth taking. The API key is never read from
the process environment, an environment variable of the same name being ignored
even when set. The file must be 0600; any other mode is a hard startup failure,
because a permissive mode leaks the credential silently.
The colour writer is the interesting one. It does not re-encode the file. It changes the value of a top-level member, or adds one in the file's own style, matching indentation, line separator, and colon spacing, and preserving key order, whitespace, escapes, and a missing final newline.
--config PATH reads and writes the named file for the current session. It does
not create or rewrite the default file, and a later run without the flag uses the
ordinary search order. The selected file must already exist and be mode 0600.
orcli.json.example at the repository root shows every
top-level member this client reads, including every optional block below, in
one file. Copy it to ~/.orcli.json, fill in the values, and set its mode to
0600 (chmod 0600 ~/.orcli.json) - a more permissive mode is refused.
The optional notion block carries its integration credential and is preserved
as raw JSON so unknown members survive configuration reads. For example:
{"notion":{"api_key":"your-integration-token"}}api_key can name a macOS Keychain item instead of carrying the credential
itself, so orcli.json never has to hold the raw key (issue #67). Add
api_key_keychain naming an entry in a keychain.items block, where each
entry is the account/service pair security find-generic-password's own
-a/-s flags match against:
{"api_key_keychain":"openrouter","keychain":{"items":{"openrouter":{"account":"john@smith.com","service":"private-openrouter-api"}}}}Precedence is deliberate: an inline api_key, when present, always wins
over api_key_keychain. Migrating means removing api_key from the file,
not just adding api_key_keychain beside it - a stale api_key sitting
next to a working Keychain reference would otherwise keep being used with no
error anywhere, which is the opposite of what this feature is for.
Manage named items without ever typing a secret into the interactive session (which would echo it into the transcript) or display one on screen:
orcli keychain set openrouter john@smith.com private-openrouter-api # prompts for the secret, no echo
orcli keychain list # names, accounts, services - never secret values
orcli keychain delete openrouter # removes the config reference AND the Keychain itemorcli keychain set reads the secret from the terminal with echo
suppressed when stdin is a real terminal (golang.org/x/term.ReadPassword);
piped input is read as a single line instead, which has no terminal echo to
suppress in the first place. The secret reaches the underlying security add-generic-password command as one argument - the one exposure this
approach cannot avoid while driving that CLI rather than the Security
framework's own API directly, visible only to another local process
inspecting the process list at that moment, and never written to this
program's own logs, stdout, or any error it returns.
Separate named entries support distinct per-provider credentials and a
dedicated test key (keychain.items.test in the example above), so running
the test suite never has to revoke or share the credential a real session
uses. Only macOS is supported; orcli keychain and api_key_keychain both
report a clear, named error on any other platform rather than attempting to
run a security binary that is not there.
The optional github block carries an api_key string and preserves unknown
members in the same way:
{"github":{"api_key":"your-github-token"}}The value is treated as an opaque string. Task operations also require an account login and explicit repository allowlist:
{"github":{"api_key":"your-fine-grained-token","login":"your-login","repositories":["owner/repo"]}}The token needs Issues read/write and Metadata read access on each listed
repository. @github tasks reads assigned issues and comments and can create,
update, change state, and comment on issues. Pull requests are excluded from
task listings. github_tasks is not added to the tool set unless all three
settings are valid. The app never expands the repository allowlist. Keep the
configuration file at mode 0600.
The optional mcp block names Model Context Protocol servers to launch as
subprocesses and speak to over their standard input and output:
{"mcp":{"servers":[{"name":"files","command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/path"],"env":{"API_KEY":"value"}}]}}Each server needs a unique name and a command; args and env are
optional. env is the only source of a server's environment beyond PATH:
nothing is inherited from the orcli process, on the same grounds the API key
is never read from it. A server is not started until the model calls
mcp_list_resources or mcp_read_resource naming it, and is kept running for
reuse by later calls in the same session once it is. Only resource listing
and reading are offered; a server's own tools and prompts are not surfaced.
A server is also reachable remotely over the Streamable HTTP transport
(issue #62) - the one the MCP specification currently recommends for new
integrations; the older HTTP+SSE transport is not supported. Name url
instead of command, with optional headers (a bearer token or any other
credential the remote server itself needs, never read from this process's
own environment, the same rule env follows for a local server):
{"mcp":{"servers":[{"name":"watchgoose","url":"https://watchgoose.example.com/mcp","headers":{"Authorization":"Bearer your-token"}}]}}A server names command (local) or url (remote), never both and never
neither - either is refused by name at configuration read time. A local and
a remote server can be configured side by side; mcp_list_resources and
mcp_read_resource work identically against either kind once a server is
named, since both speak the same JSON-RPC shape underneath. A remote
server's session (if it assigns one) is torn down with a best-effort DELETE
when the session ends, the same way a local server's subprocess is stopped.
Task operations in Notion require the integration token to be shared with the task data source, plus explicit IDs/property names that bind task operations to one source and one assignee:
{"notion":{"api_key":"your-integration-token","task_data_source_id":"00000000-0000-0000-0000-000000000000","task_assignee_property":"Assignee","task_assignee_id":"00000000-0000-0000-0000-000000000000","task_status_property":"Status"}}@notion tasks lists tasks assigned to that person, fetches their page and
content, and supports creation, property/title updates, status changes, and
comments. New tasks are placed in the configured data source and assigned to
the configured person. Explicit page operations first verify the page belongs
to that data source. Both integrations use Orcli's own configured credentials;
they do not inherit ChatGPT or other connector credentials. Tool approval and
OPENROUTER_TOOLS file rules continue to control writes. Writes are not retried
automatically: a transport or server failure can leave an unknown outcome, so
inspect the issue/task before trying again.
The current integration inventory, exact permissions, gaps, and verification limits are recorded in the GitHub and Notion task integration audit.
With orcli --debug, the interaction stream is written to
.orcli-debug.jsonl in the working directory from startup. During a session,
/trace starts capture, /trace status reports whether it is active, and
/trace off stops it. Runtime capture records from the moment it is enabled.
The enabled state is saved in the active configuration file and restored at the
next startup; an older file without a trace member keeps the default, disabled
state.
The stream includes user and assistant messages, slash commands, tool calls and
results, request errors, and stream completion. The file is mode 0600;
configured provider credentials, secret-bearing fields, known token formats,
and long opaque token-like strings are redacted before each record is written.
Compaction is checked at 75% of the window before every request, since a request past the window is refused outright and the turn is lost with it. The summary is a system turn carrying a marker, so a second compaction replaces it rather than summarising a summary. The opening instructions survive and are held out of the summary request.
Cost is a session ledger in memory, summed from the usage event of every response the session makes. It belongs to the session and not to a conversation, since money spent is not undone by clearing the conversation it was spent on.
The recurring shape: a missing precondition is an ordinary message rather than a refusal, so the interface still opens and reports it; a dangerous precondition is a hard failure; a failure with partial success keeps the partial; a cancellation the reader asked for is not a fault; and every diagnostic is bounded and credential-filtered, because it is shown in a pane and ends up in the terminal scrollback.
The Makefile is written in the syntax common to BSD make and GNU make, and
repetition is done with a shell loop rather than a make loop so the file parses
the same way under both. Every artifact is written under build/, and the
binary is build/orcli.
make build build the binary into build/orcli
make test run the suite
make check run the suite under the race detector
make lint gofmt check and go vet
make crossbuild compile and vet every supported target
crossbuild covers darwin, linux, freebsd, openbsd and netbsd on amd64 and
arm64, with CGO_ENABLED=0. DragonFly is absent because the SQLite driver
cannot be built for it, and Windows is absent because the bootstrap loader
compares devices through syscall.Stat_t.
Both make build and make crossbuild stamp the binary with a UUID
generated fresh for that invocation (-ldflags -X main.buildID=...), shown
alongside the version by orcli version, orcli --version, and the
startup report. The version alone cannot tell two builds of the same commit
apart; the build ID can, which is what answers whether the binary in front
of a reader debugging a session is the one a given build actually produced.
A plain go build ./... with no -ldflags leaves it unset, and the version
report omits it rather than print a mark that was never stamped in.
The binary is not produced yet, because there is no cmd/orcli in the tree. The
build target reports that rather than failing, so the library packages still
build and vet.
See LICENSE.