Skip to content
glenjbarberPublic

About

OpenRouter.ai Command Line Client

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

475 Commits

Folders and files

Repository files navigation

orcli

OS

Architecture Architecture Architecture

License

Go Version Go Reference

Last Commit Go CI

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.

Status

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

Liquid Console

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.

Design overview

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.

Packages

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.

Rendering

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.

Input

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.

Conversation

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.

Tools

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.

API client

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.

Configuration

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"}}

Resolving api_key from the macOS Keychain

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 item

orcli 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 and cost

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.

Error handling

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.

Build

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.

License

See LICENSE.

About

OpenRouter.ai Command Line Client

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages