Turly
A Postman-style API client that lives in your terminal. Vim keys, plain-file workspaces and OpenAPI import.
cargo install turly
turly -i https://petstore3.swagger.io/api/v3/openapi.json
Three-pane TUI
Request tree, request editor and response viewer, with the mouse if you want it.
Vim keys
Modal editing everywhere, a command box, search, and every key remappable.
Plain-file workspaces
One YAML file per request. Diff them, review them, commit them.
OpenAPI import
Build a whole workspace from an OpenAPI 3.x or Swagger 2.0 spec, file or URL.
Real auth
Bearer, Basic, API key and OAuth2 with browser sign-in, plus a workspace default.
Secrets stay out of git
Environments and variables, with secret values kept in a gitignored file.
Responses are syntax-highlighted and searchable, every request is kept in history, and any request can be copied as curl. Ten themes ship in the box, and you can add your own in TOML.
New here? Start with Getting started, then read Editing requests and Authentication.
Getting started
Install
# from crates.io
cargo install turly
# from source
git clone https://github.com/mwithington/turly && cd turly
cargo install --path .
Prebuilt binaries for macOS, Linux and Windows are attached to each GitHub release.
Try a spec without leaving anything behind
turly -i petstore-openapi.json
-i / --import accepts a file or URL. It imports into a temporary workspace that is deleted when you quit and is never registered.
Build a workspace from a spec
turly import https://petstore3.swagger.io/api/v3/openapi.json --dir petstore
turly petstore
Start empty
turly init my-api
turly my-api
turly with no arguments opens the workspace in the current directory, or the most recently used one. Inside the app, :wsnew <path> and :import <spec> do the same as the commands above.
First request
Imported requests point at {{base_url}}. Set it for your environment with :set base_url=https://api.example.com, and secrets with :secret token=.... Or edit them all at once with :vars.
Move to a request with j/k, press Enter to open and send it, and press ? for the key reference.
Editing requests
Press i or a on the URL or on a tab to edit it in the Vim editor. Esc leaves it, Ctrl-s saves, and Ctrl-r sends.
Tabs
Params status=available one key=value per line, "# " disables a line
Headers Accept: application/json one Name: value per line
Auth token: {{token}} fields depend on the type (press t to change)
Body { "name": "doggie" } json, text or form (key=value lines)
Example { "name": "doggie" } read-only sample from the OpenAPI spec
:example copies the Example tab into Body. Ctrl-e in editor normal mode opens the buffer in $EDITOR.
Variables inline
{{vars}} show their resolved value (red) when the cursor is not on that line, and the raw {{name}} while it is. Unresolved ones stay as {{name}} in yellow.
Responses
The response pane shows the body (pretty-printed and syntax-highlighted), headers, and status. / searches the body. :save <path> writes the full body to a file, and y copies the request as curl.
Authentication
Each request has an auth type, shown on the Auth tab. Press t to cycle through them:
none → bearer → basic → apikey → oauth2 → inherit
Every field accepts {{variables}}, so keep real values in the environment (:secret token=...) and put {{token}} in the request.
| Type | What is sent |
|---|---|
bearer | Authorization: Bearer <token> |
basic | Authorization: Basic base64(user:pass) |
apikey | a custom header (default X-API-Key), or a query parameter with in: query |
oauth2 | Authorization: Bearer <access token>, fetched for you (below) |
inherit | whatever the workspace default is |
A header you add yourself on the Headers tab named Authorization always wins over bearer, basic and oauth2.
Workspace default
Set the auth on one request, then run :authws. It is saved as auth: in turly.yaml, and every request set to inherit uses it. New requests start as inherit. :authws clear removes it.
# turly.yaml
name: my-api
auth:
type: bearer
token: "{{token}}"
A request set to none stays unauthenticated, which is what OpenAPI import uses for public endpoints.
OAuth2
The Auth tab for oauth2 is one field: value per line:
grant: client_credentials
token_url: https://auth.example.com/oauth/token
auth_url:
client_id: {{client_id}}
client_secret: {{client_secret}}
scope: read write
username:
password:
grant | Use | Fields needed |
|---|---|---|
client_credentials | machine to machine | token_url, client_id, client_secret |
password | legacy username and password login | plus username, password |
code | sign in with a browser (authorization code with PKCE) | auth_url, token_url, client_id |
On send, Turly gets a token, adds it as a bearer header, and caches it in memory for the session. It reuses the token until shortly before it expires, then refreshes it with the refresh token if the server gave one, or gets a new one. :logout clears the cache. Credentials are sent in the request body (client_secret_post).
Browser sign-in (code)
Sending the request opens your browser at auth_url. After you approve, the provider redirects to http://127.0.0.1:8765/callback, where Turly is listening, and the page tells you to return to the terminal. Turly waits two minutes. If the browser doesn't open, the sign-in URL is in the status line.
Register http://127.0.0.1:8765/callback as the redirect URI with your provider. Add a port: line to use a different port.
From OpenAPI
Importing a spec with an oauth2 scheme fills in the URLs and scopes from the spec, using the authorization code flow if it has one, then client credentials, then password. client_id and client_secret become secret variables for you to fill in.
Not supported
Digest auth, AWS SigV4, client certificates (mTLS), the OAuth2 implicit and device flows, and folder-level auth. Tokens are not saved to disk, so a code login repeats on each launch. The copied curl command (y) can't include an OAuth2 token because it is fetched at send time.
Environments and variables
Variables resolve from the active environment. Use them anywhere in a request as {{name}}. {{$NAME}} reads an OS environment variable.
Switch environments with e or :env <name>, and create one with :envnew <name>.
Editing
:set key=valuesets one variable.:secret key=valuesets a secret.:varsopens the whole environment as text, onekey=valueorsecret key=valueper line.Ctrl-sor:wsaves,:wqsaves and closes, and:qorEsccloses.
Values longer than 20 characters are shown cut off with … in the :vars editor. Move the cursor onto a line and the window widens to show it in full.
Secrets
Secret values live in .turly/secrets.yaml, which turly init adds to .gitignore. The environment file only records their names, so it is safe to commit.
Workspaces
A workspace is a plain directory:
my-api/
├── turly.yaml name, timeout, active environment
├── requests/
│ └── pet/
│ └── find-pets-by-status.yaml
├── environments/
│ └── default.yaml variables and the names of secret variables
└── .turly/ gitignored: secrets.yaml, history.jsonl
A request file
name: findPetsByStatus
method: GET
url: "{{base_url}}/pet/findByStatus"
params:
- { key: status, value: available, enabled: true }
auth: { type: bearer, token: "{{token}}" }
body: { type: none, content: "" }
docs: "Finds pets by status."
order: 3 # optional: pins the position in the sidebar
Folders sort first, then requests by order, then by name.
Several workspaces
All registered workspaces are remembered in ~/.config/turly/workspaces.yaml. Open one with turly <path>, or switch inside the app: press W (or run :ws with no argument) for a picker listing every known workspace, with a ● on the current one. :ws <name or path> switches directly.
Several workspaces in one folder
Launch Turly in a folder that holds workspaces, or point it at one (turly ~/apis). It finds every workspace in that folder and up to two levels below it, skipping hidden folders, target and node_modules. When there are two or more, they appear as the top-level rows of the sidebar. Expand as many as you like to see their requests side by side:
▾ alpha
GET Get users
▾ gamma ●
GET Ping
▸ history
▸ team-beta
The ● marks the active workspace: the one whose environment, variables and history the commands use. Opening a request in another workspace makes that workspace active. A folder with a single workspace shows the plain tree.
| Key | On a workspace row |
|---|---|
l / h | Expand / collapse it |
Enter or click | Collapsed: expand and activate. Expanded but not active: activate. Active: collapse |
z M | Collapse every workspace |
z R | Expand every workspace |
Activating a workspace closes the open request, so it is refused while that request has unsaved edits. Which workspace is active at startup is the most recently used one in the folder, else the first. W still lists every workspace Turly knows about, in any folder.
Switching is refused while the open request has unsaved edits. The open request and response are cleared on a switch, and each workspace keeps its own active environment.
OpenAPI import
turly import <spec> (or :import) accepts OpenAPI 3.0/3.1 and Swagger 2.0, as JSON or YAML, from a file or an http(s) URL. Swagger 2.0 is converted to 3.0 in memory first.
- Tags become folders, operations become requests, in the spec's order.
serversbecome environments with abase_urlvariable. Path parameters become{{vars}}with example values.- Query and header parameters are listed, enabled when the spec marks them required.
- JSON and form bodies are pre-filled from the spec's
example/examples(or a schema'sexample/default), else generated from the schema's types. The original stays in the Example tab. - Bearer, Basic and API key security schemes become auth, with secret variables to fill in.
- Summaries and descriptions show up in each request's Docs tab.
Import creates a new workspace. Re-importing over an existing one is not supported yet. For a throwaway one, use turly -i <spec>.
Keys and Vim motions
| Key | Action |
|---|---|
h j k l | Move, collapse and expand folders, switch tabs (on a workspace row, l/h expand/collapse it) |
gg / G | Top / bottom |
Ctrl-d / Ctrl-u | Half page down / up |
Ctrl-w + h j k l | Focus a pane (Ctrl-h/j/k/l also work) |
Ctrl-w w | Next pane |
Tab / Shift-Tab | Next / previous tab |
Enter | Open a request and send it |
i / a | Edit the URL or the open tab in the Vim editor |
Esc | Leave the editor, or clear the sidebar filter |
Ctrl-r | Send the request (also while editing) |
Ctrl-s | Save the request |
: | Command box |
/, n, N | Search the focused pane, next, previous |
z M / z R | Collapse / expand every workspace row |
W | Pick a workspace (j/k or click, Enter opens, Esc closes) |
o | New request |
dd | Delete a request (asks first) |
m / t | Cycle the method / the auth or body type |
e | Next environment |
y / Y | Copy the request as curl / copy the response (body, or headers on that tab) |
Ctrl-w < > + - | Resize panes (Ctrl-w = resets) |
? | Key reference |
q / Ctrl-c | Quit |
Search
/ is context-sensitive. In the sidebar it live-filters requests by name, method and URL. In the response it highlights matches in the body. In the editor it searches the buffer.
Editor motions
Each editable field is a modal Vim buffer (normal, insert, visual, . repeat). Beyond the built-in motions, normal mode adds F T ; , ^ C, and d/c/y with $ 0 ^ e b f F t T ; ,, for example d$, dtB and cF".
These custom operators aren't repeated by ..
Mouse
Click folders, requests, tabs and fields, drag to select, and scroll with the wheel. Drag the border between panes to resize them. Hold Shift for the terminal's native text selection, or set mouse = false in the config.
Remapping
Everything is remappable. See Configuration.
Commands
Press : and type. Tab completes, and the list shows what each command does.
| Command | What it does |
|---|---|
:w / :wq | Save / save and quit |
:q / :q! / :qa | Quit (warns on unsaved changes) / quit and discard / quit out |
:new [name], :mkdir <name>, :rm, :dup, :rename <name>, :discard | Manage requests and folders |
:copy | Copy the response (body, or headers on that tab) |
:send, :method <M>, :curl, :save <path> | Send, set the method, copy as curl, save the response body |
:ws [path|name], :wsnew <path> | Switch to a workspace (no argument opens the picker) / create one |
:env [name], :envnew <name> | Switch to / create an environment |
:set k=v, :secret k=v | Set a variable / a secret in the active environment |
:vars | Edit the active environment line by line |
:authws [clear] | Make this request's auth the workspace default |
:logout | Forget cached OAuth2 tokens |
:example | Reset the body to the spec's example |
:import <spec> [dir] | Build a workspace from an OpenAPI or Swagger spec |
:theme [name] | Switch theme |
:help | Key reference |
/ opens the search box instead.
Configuration
~/.config/turly/config.toml (or $XDG_CONFIG_HOME/turly/):
theme = "nord"
mouse = true # false keeps native terminal text selection
# Remap or add keys. Chords are space separated.
[keys]
"ctrl-s" = "save"
"g d" = "delete_request"
"x" = "quit"
Actions
up down left right top bottom half_down half_up focus_left focus_right focus_up focus_down focus_next next_tab prev_tab open edit send save command search search_next search_prev cancel quit help new_request delete_request cycle_method cycle_kind next_env copy_curl copy_response pane_wider pane_narrower pane_taller pane_shorter pane_reset workspaces collapse_all expand_all
Themes
Presets: turly (dark), turly-light, gruvbox, nord, catppuccin, catppuccin-latte, solarized, solarized-light, tokyonight, dracula and mono.
Switch with :theme <name>, or set theme in the config. The default follows your terminal background when it reports one (COLORFGBG), and truecolor falls back to 256 colors when COLORTERM doesn't advertise it.
Your own
Add ~/.config/turly/themes/<name>.toml, then :theme <name>:
base = "nord" # optional starting preset
accent = "#ff8800"
string = "#a3be8c"
Slots: bg fg dim accent border selection search ok warn err info key string number keyword comment.
Limitations
- OAuth2 covers client credentials, password and authorization code. Tokens live in memory only. See Authentication.
- No scripting, tests or assertions, and no Postman collection import yet.
- HTTP only: no WebSocket, GraphQL or gRPC.
- The cookie jar lasts for the session and isn't saved to disk.
- Responses over 10 MB are truncated in the viewer.
:save <path>writes the full body. - Inline variable values are revealed per line, not per variable.
- Import makes one folder level (the first tag).