Skip to content

Repository files navigation

tapto-code

CI License: Apache 2.0 C++17

A small, dependency-light command-line AI coding assistant for Windows and Linux. Chat with an AI model in your terminal and let it work in the current folder — read and edit files, search the tree, and run commands you've approved — all from a single self-contained C++17 binary.

Highlights

  • No permission prompts — it never interrupts to ask. Its reach is bounded by design instead: run it in a folder and it stays there, and it only runs the commands you configured beforehand.
  • Multiple providers — Claude, any OpenAI-compatible endpoint (vLLM, llama.cpp, LM Studio, OpenAI itself), or Gemini. Name as many as you like in one config, including two local servers speaking the same API, and switch with --provider. The API key is read from an environment variable or config.
  • File tools — the model can view, create, and edit files and search the tree, confined to the directory you launch it in (sandboxed: no escaping via .., absolute paths, or symlinks).
  • Allow-listed commands — run_command only runs commands you've explicitly added (with optional %1 / %p1 placeholders); it is never a general shell.
  • Three-scope config — system / global / project, with git-style precedence.
  • Self-contained — one binary; the shared tapto code (libtapto) and its dependencies (nlohmann/json, cpp-httplib, OpenSSL) are fetched at build time, each pinned to a version.

Licensed under the Apache License 2.0.

Build

Requires CMake 3.16+ and a C++17 compiler (MSVC, gcc, or clang).

cmake -S . -B build
cmake --build build --config Release

The binary is produced at build/tapto-code (Linux) or build/Release/tapto-code.exe (Windows / MSVC).

Tests are plain executables with assertions of their own — nothing to fetch or install:

ctest --test-dir build --output-on-failure

libtapto

The code every tapto program shares — the config store and secret resolver, provider resolution, and the three provider clients (Claude, OpenAI-compatible, Gemini) with the agent loop inside — is one static library, libtapto, fetched at a pinned tag from its own repository and shared with tapto-word, tapto-vnc and their siblings; a fix lands there once and each program takes it by bumping LIBTAPTO_TAG. What is this program's own: the tool table (src/tools.cpp), the allow-listed commands, the CLI and chat loop, and src/ui.cpp, which gives the tapto::ui functions the library declares their terminal bodies.

The library's unit tests run under ctest alongside this program's.

Dependencies

Pinned by libtapto and fetched automatically at configure time via CMake FetchContent (needs git + network on the first configure):

  • nlohmann/json v3.11.3 — JSON.
  • cpp-httplib v0.15.3 — HTTP client used by the provider clients to talk to the chat APIs.

OpenSSL is required (the provider APIs are HTTPS). It must be installed and findable by CMake. On Linux install your distro's libssl-dev / openssl-devel. On Windows/MSVC, if it isn't auto-detected, point CMake at it:

cmake -S . -B build -DOPENSSL_ROOT_DIR=C:/path/to/openssl

Config

Three scopes, in increasing order of precedence:

Scope Flag Location
system --system %PROGRAMDATA%\tapto\config / /etc/tapto/config
global --global ~/.tapto/config (user home)
local --local per-folder, stored centrally at ~/.tapto/projects/<folder>/config

When reading, local overrides global overrides system. Writes default to the local scope; pass --global or --system to target another scope.

A fourth, read-only scope sits above all three: policy, what an organization mandates. See Enterprise policy below.

Local (project) settings are stored centrally, keyed by the working directory, under ~/.tapto/projects/ — not inside the project folder. So nothing is written into your repo, and cloning a repo can't bring its own config or runnable commands with it.

Examples

tapto-code --global config set claude-api-key sk_ant_xx23982932
tapto-code config set max-output-tokens 32000   # writes to this folder's local config
tapto-code config get claude-api-key      # effective value across scopes
tapto-code config list                    # all effective values
tapto-code config list --show-origin      # prefix each entry with its scope
tapto-code --global config list           # only the global scope
tapto-code config unset model             # remove from local

Enterprise policy

An administrator can fix any config key for every user, and confine which providers they may use, through Group Policy. A key set by policy overrides every user scope, config set refuses to change it, and config list --show-origin shows it as policy. A key the policy does not set is left to the user, so a policy can be as narrow as one locked endpoint or as wide as the whole configuration.

Where policy is read from:

Platform Source
Windows HKLM\SOFTWARE\Policies\Centlake\tapto (machine, wins) and HKCU\...\tapto
others /etc/tapto/policy, same key = value format as the config file

Value names are the config keys, verbatim. Strings are taken as they are, a DWORD becomes its decimal text (so a boolean is 1/0), and a multi-string or an ADMX list subkey becomes one comma-separated value. Two subkeys are special: settings holds free-form keys read as if they sat in the parent (it is where the template's Additional settings list writes), and commands is the organization's command allow-list, described below. Windows lets only administrators write under SOFTWARE\Policies and Group Policy re-applies it on every refresh, which is why the registry is used rather than a file under ProgramData; the --system file remains the place for defaults users may override.

admx/tapto.admx and admx/en-US/tapto.adml are the Group Policy template. Copy them into %SystemRoot%\PolicyDefinitions (or the domain's Central Store) and the settings appear under Administrative Templates > Centlake > tapto; Intune takes the same files through ADMX ingestion. The template covers the default provider, a work provider block, request limits, the restrictions below, the command allow-list, and a free-form name/value list for every other key.

Three keys exist only as policy:

allowed-providers = work, review    # the only names --provider or `provider =` may use
allow-user-providers = 0            # only blocks the policy itself defines are usable
allow-user-commands = 0             # only commands the policy defines may be run or added

Confinement holds only when the policy also fixes where a permitted block points. With either provider restriction set, a -provider-url from a user scope is refused: the endpoint of a permitted provider is the policy's, or the vendor's default. The template makes the work block's URL a required field for the same reason; a policy written by hand should set it too.

A policy can also allow-list commands for everyone: on Windows the values of the commands subkey (the template's Allow-listed commands setting), name = command line; elsewhere the file /etc/tapto/policy-commands, in the same format as a commands store. They merge above the user's own commands, so a name the policy defines cannot be redefined, and command list shows them with the scope policy. With allow-user-commands = 0 they are the only commands the agent can run, apart from the built-ins.

Never put an API key in a policy: Group Policy objects are readable by every account in the domain. Set work-api-key to a reference instead (wincred:tapto/work, env:WORK_AI_KEY, cmd:..., see above) and distribute the credential separately, or point work-provider-url at a gateway that authenticates the user.

Every request tapto-code sends names it in the User-Agent header, as tapto-code/<version> (<commit>; <os>), for example tapto-code/1.0.5 (v1.0.5; windows). A gateway in front of the provider (LiteLLM, for one) can log it to see which versions are in use, or refuse a version that is too old: tapto-code shows the gateway's error message to the user, and does not retry a 4xx. Versions before 1.0.5 send no header of their own, so a rule that requires tapto-code/ in the header turns those away too. The header is set by the client, so it tells you which version an honest client is running; it is not an access control.

tapto-code --policy config list      # what the policy sets, and nothing else
tapto-code config list --show-origin # policy entries are marked "policy"
tapto-code --policy command list     # the commands the policy allow-lists

Chat

Running tapto-code with no subcommand starts an interactive chat with the configured AI provider (chat is the default action); --provider <name> picks a different one for this session. It prints a > prompt, reads a line, sends it to the provider, prints the reply, and repeats. Type /exit (or Ctrl-D) to quit.

In-session slash commands: /clear (reset the conversation — useful to recover after filling the model's context window), /compact, /env, /list-commands, /add-command <name> <command...>, /resume, and the folder commands below.

Resuming a conversation. After every turn the conversation is saved to session.json beside the folder's local config (under ~/.tapto/projects, never in the project; /env shows the path). If a chat ends by accident — Ctrl-C, a closed window — start tapto-code --resume in the same folder, or type /resume, to continue it (if you already started typing, /resume still brings back the conversation that was saved when the session started). The folders granted with /add-folder come back with it, and a prompt that was still being answered is printed so you can send it again; the work the model did on that last turn is in your files but not in the conversation. A new conversation replaces the saved one with its first turn.

/resume also undoes /clear: the cleared conversation is set aside, and stays on disk until your next turn, so --resume brings it back too. When a conversation is already under way, /resume sets that one aside instead of dropping it, and a second /resume switches back. The history is in the provider's own format, so it resumes only with a provider of the same type (claude, openai or gemini). The file holds whatever the model read, as the trace file does.

Reading beyond the working directory. The file tools are confined to the directory tapto-code was started in. To let the model read something else — a library the project depends on, a sibling repository — grant it read-only:

/add-folder C:\proj\libfoo          read-only
/add-folder C:\proj\libfoo rw       read-write
/list-folders
/remove-folder libfoo

While a folder is granted the model gets four more tools — list_folders, list_files, read_file and search_files — that list, read and search under the working directory and the granted roots and nothing else; they cannot create or change a file. Files are addressed as <label>/<relative path>, where the label is the folder's last path component (shown on grant and by /list-folders); a path without a label is relative to the working directory, as it is for every other tool.

/list-folders always starts with the working directory — the folder tapto-code was started in — marked as such. It is not a grant: /remove-folder refuses it, and it cannot be made read-only.

The working-directory tools reach into granted folders too, as <label>/<path> or by absolute path, by one rule: a read is allowed anywhere granted, a write only where the grant is rw. So the editor's view, find_files and the built-in cat, ls, head, tail, wc and tree work under any grant, whichever spelling the model picks; create, str_replace and insert need rw, with the same refusal of anything under .git; and an allow-listed shell command may run with its cwd in an rw folder, never a read-only one — letting the model edit a folder and letting it run the project's build there is the same trust. A subfolder of the working directory with the same name as a label wins over the label. Granting a folder again with a different mode changes the mode.

A grant lasts for the session. The read-only commands exist in tapto-word too.

First run: if no provider/api-key is configured, tapto-code prompts for them interactively and saves them to the global (~/.tapto) config, then starts the chat. You can also set them manually instead:

tapto-code --global config set provider claude             # claude | openai | gemini
tapto-code --global config set claude-api-key sk_ant_xx23982932
tapto-code                                                 # starts the chat

Chat config keys:

Key Required Default (per dialect)
provider yes claude — which provider block to use
<name>-provider-type — claude / openai / gemini — the API to speak
<name>-api-key yes — or the vendor's environment variable; may be an env: / cmd: / wincred: reference
<name>-provider-url no claude: https://api.anthropic.com, openai: https://api.openai.com, gemini: https://generativelanguage.googleapis.com
<name>-model no claude: claude-sonnet-4-6, openai: gpt-4o, gemini: gemini-2.0-flash
<name>-reasoning-effort no unset — sent as reasoning_effort by the openai dialect only (e.g. low, medium, high); ignored with a warning on other dialects
max-output-tokens no 16000 — raise it for long replies (large tables, reports)
max-tool-iterations no 200 — max tool-call rounds per reply before the agent stops
connection-timeout no 30 — seconds to wait for the provider to accept the connection
read-timeout no 300 — seconds to wait for the whole answer; the reply is not streamed, so raise it for a slow local model (a timeout shorter than the generation retries the same request until the retry budget is spent)
print-cot no true — show the model's intermediate reasoning/text during tool calls; set false to keep it in the trace file only
system-prompt no built-in prompt — replaces it with a one-line value
system-prompt-file no unset — replaces the built-in prompt with a text file's contents (see below)
system-prompt-append no unset — added after the prompt, so project rules don't need a copy of the built-in one
system-prompt-append-file no unset — same, from a text file; added after system-prompt-append
trace-file no unset — set to a path to enable diagnostic logging

System prompt

The config holds one key = value per line, so a prompt longer than a line goes in a text file:

tapto-code config set system-prompt-append-file prompt.md   # this project only
tapto-code --global config set system-prompt-file C:/prompts/base.md

system-prompt or system-prompt-file replaces the built-in prompt, which is what tells the model how to find and run the allow-listed commands; if both are set, the one from the more specific scope wins, and the file on a tie. system-prompt-append and then system-prompt-append-file are added after whichever prompt is in effect, separated by a blank line — usually what you want for project rules. When the prompt is set by group policy, only the policy's own append keys are added. A relative path in the local (project) config is resolved against the folder tapto-code was started in, one in the global or system config against that config file's folder, and a policy path must be absolute. A file that is missing, empty or binary is skipped with a warning.

Naming providers

A provider has a name and a dialect. The name picks a block of keys and can be anything; the dialect is one of the three request shapes tapto-code speaks, named by that block's -provider-type. So two local servers that both expose an OpenAI-compatible API can still be told apart:

qwen-provider-type = openai          gemma-provider-type = openai
qwen-provider-url  = http://box:8000 gemma-provider-url  = http://box:8081
qwen-model         = Qwen3-VL-30B    gemma-model         = gemma-3-27b
qwen-api-key       = local           gemma-api-key       = local
tapto-code --provider gemma          # or 'provider = gemma' as the default

claude, openai and gemini need no block at all — used as a name, each means its own dialect with that vendor's defaults. Asking for a name the store does not define lists the ones it does. The resolved provider, dialect, model and URL are printed in the startup banner, so a block wired to the wrong dialect shows up immediately rather than as malformed requests.

The unscoped model, provider-url and api-key apply only to the default provider — the one provider names. Otherwise a local endpoint's URL would be sent to a hosted vendor, or a hosted key to a local server.

An older store using provider-type = claude with an unscoped api-key and model keeps working: provider-type is read as the default provider's name when provider is absent.

The config store is shared with tapto-vnc, which reads the same provider blocks.

API key resolution: <name>-api-key first, then the vendor's environment variable — ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY — then the unscoped api-key for the default provider. The block's own key wins on purpose: an environment variable taking precedence would send a real vendor key to whatever <name>-provider-url points at, and a local server will log it. A server that checks no key still needs one; any non-empty value does.

Keeping keys out of the config file

An api-key value may say where the key lives instead of holding it:

Value Reads from
sk-ant-... the value itself
env:ANTHROPIC_API_KEY an environment variable
cmd:pass show anthropic the first line of a command's output
wincred:tapto/work-claude a Windows Credential Manager generic credential
tapto-code --global config set work-api-key wincred:tapto/work-claude
cmdkey /generic:tapto/work-claude /user:tapto /pass    # prompts for the key

cmd: is the general escape hatch — pass, gopass, op read op://vault/item, gcloud, security find-generic-password, or a git credential helper all work, and the store keeps saying which provider uses which secret. The command's stderr and stdin are left attached to the terminal, so a helper that needs to unlock a vault can prompt.

wincred: reads a generic credential, which Windows encrypts under your user account with DPAPI. That protects the key from a config file that gets backed up, synced, screen-shared or pasted into an issue — it does not protect it from code running as you, which can read the credential without a prompt. It is the same trade git's wincred credential helper makes. Both blob encodings are accepted, so credentials written by cmdkey or by git's helper are read correctly.

A reference that fails — variable unset, command non-zero, credential missing — is a hard error naming the problem. It never falls through to the next source, because that is how one endpoint ends up being handed another one's key.

Writing a literal key to config (via config set or the first-run prompt) prints a one-time plaintext-storage warning; a reference does not, since storing one is the remedy. config list masks literal keys (e.g. sk_ant...cdef) but shows references in full, as they name a location rather than a secret; use config get <name>-api-key for the raw value.

Diagnostic logging: off by default. Set trace-file to a path (tapto-code config set trace-file ./tapto.log) to append request/response diagnostics there; unset it to disable.

If a reply hits the output-token limit it is cut off and marked [truncated: hit max output tokens - raise max-output-tokens]; raise max-output-tokens to allow longer responses.

Tools

During a chat the model can call these local-filesystem tools (relative to the directory tapto-code is launched from):

  • str_replace_based_edit_tool — view / create / str_replace / insert. Declared as Claude's built-in text_editor_20250728 for Anthropic, and with an explicit JSON schema for OpenAI/Gemini.
  • find_files — find files by wildcard pattern (*, ?), optionally grepping their contents.
  • list_commands — list the built-in and allow-listed commands (see below).
  • run_command — run one built-in or allow-listed command by name and return its output.

These edit real files on disk. create refuses to overwrite an existing file; str_replace requires the target string to be unique.

Line endings: files are read and written byte-exact, and view shows the model each line with its CR stripped — so the model composes edits in LF terms whatever the file uses. Matching therefore treats a line ending as a line ending: an old_str written with \n matches CRLF text, and vice versa. Text written back takes the ending of the text it replaces, so an edit to a Windows file stays CRLF and a one-line replacement that becomes several lines uses the file's convention for the new ones. insert splices at a byte offset rather than rebuilding the file, so a file with mixed endings — which a long Windows history produces — keeps every ending the edit didn't touch. Two occurrences that differ only in their line endings count as ambiguous and are refused, since they are indistinguishable in what the model was shown.

Sandbox: the file tools (str_replace_based_edit_tool, find_files) are confined to the directory tapto-code was started in and its subdirectories. Paths that resolve outside that subtree — via .., an absolute path, or a symlink — are rejected. Inside the tree, a repository's git directory is read-only: a writable config (core.fsmonitor, core.hooksPath) or hooks/ would turn any allow-listed git command — even git status — into arbitrary code execution. The rule is not the name .git: a directory holding HEAD, objects/ and refs/ is one, whatever it is called, which covers a linked worktree, git init --separate-git-dir, and a bare repo sitting in the tree. A symlinked .git is caught as written, before the link is resolved. Reading any of it is still allowed. The same rule applies to run_command: a command's cwd and its %p path arguments are refused there too (the read-only built-ins may still be pointed at it).

What the sandbox does not cover: allow-listing a command that runs files from the tree — make, npm run build, cmake, a test runner — gives the model arbitrary code execution in that tree, because it can edit the Makefile, package.json or conftest.py that command reads. That is the trust you grant by allow-listing a build, and no path rule can take it back. Hooks a repository keeps in the worktree rather than in the git directory (core.hooksPath, husky's .husky/) are ordinary editable files for the same reason.

Commands

Built-in commands (always available)

run_command ships a small set of read-only, cross-platform utilities implemented in-process (no shell), so they work everywhere — including on pure Windows, where these don't otherwise exist. They resolve paths inside the sandbox and never shell out:

Command Purpose
wc [-l|-w|-c] <file> count lines / words / bytes (all three with no flag)
head [-n N] <file> first N lines (default 10)
tail [-n N] <file> last N lines (default 10)
cat <file> print a file's contents
ls [path] list a directory (default .)
tree [path] [-L depth] show a directory tree (skips .git, build, node_modules, …)

These names are reserved — they take precedence over allow-listed commands and can't be redefined with command add.

Allow-listed commands

run_command is not a general shell — beyond the built-ins above, the agent can only run commands you have explicitly allow-listed. Commands are stored per scope (system / global / local, same precedence as config); local commands live in the central per-folder store (not in the repo), so a cloned project can't ship runnable commands. An organization can add commands above all three, and confine the agent to them, through policy. Managed with:

tapto-code command add build-debug cmake --build build --config Debug
tapto-code --global command add gs git status
tapto-code command list                 # merged, with scope of each
tapto-code command remove build-debug

To get started quickly, contrib/commands is a curated, security-conscious starter set (git, npm, cmake, make, bazel, maven, cargo, go, python, …) you can copy into your global store — see contrib/README.md.

Everything after the name is captured verbatim as the command line (so flags like --config Debug are part of the command, not parsed by tapto-code). In a chat the agent discovers them via list_commands and runs them via run_command — it can never supply arbitrary shell text, only pick a name.

Command arguments

A command template may contain positional placeholders %1, %2, … and %* (all remaining values). The agent fills them via run_command's args:

tapto-code command add commit git commit -m %1
# agent calls run_command{ name: "commit", args: ["fix: handle empty input"] }

Quoting is a non-issue by design: a command with placeholders bypasses the shell entirely and is executed as a literal argv vector, so an argument value can contain spaces, quotes, &, |, %, etc. and is passed through verbatim — nothing is re-interpreted by a shell. (A command without placeholders still runs through the shell, so it can use pipes and redirection.)

On Windows, batch wrappers like npm, npx, and yarn are .cmd files that can't be launched directly; parameterized commands targeting them are run via cmd.exe automatically, so npm %* just works. Because cmd.exe re-parses that line, argument values containing ", &, |, <, >, ^, % or a newline are refused for those commands rather than risk being interpreted.

Because values are literal arguments, the model cannot inject extra commands. Put placeholders only at data positions, not where they could become a flag or subcommand (git commit -m %1 is fine; git %1 lets the model choose the subcommand). Use -- before a placeholder if a value might start with - (e.g. rm -- %1).

Path arguments (%p1): a %p-prefixed placeholder (%p1..%p9, %p*) marks a path that must stay inside the working-directory sandbox. The value is resolved the same way as the file tools and the command is refused if it points outside (via .., an absolute path, or a symlink); the resolved absolute path is substituted. A relative value is resolved against the cwd the command runs in, so it names the same file to the agent and to the process.

tapto-code command add fmt clang-format -i %p1   # only formats files in-tree

Other commands

tapto-code version                        # print version info as JSON

File format

Plain key = value lines; # and ; begin comments.

# tapto-code
provider = claude
claude-api-key = sk_ant_xx23982932
claude-model = claude-sonnet-4-6

Part of TaptoMatic

tapto-code is a small, standalone spin-off of TaptoMatic — a larger AI-powered development platform from Centlake Software AB where teams of AI agents collaborate on software projects under your direction. Where tapto-code is a single-binary CLI you point at a folder, TaptoMatic is a local platform (web GUI) built around structured autonomy: you set the direction with goals and tasks, and agents do the work.

  • Multi-agent teams — assemble teams of agents with distinct roles that write code in parallel, review each other's work, and retry until a team leader approves. A task database with parent/child and dependency relationships keeps everything coordinated.
  • Goal-driven development — define high-level goals; a goal agent tracks completion percentage and spawns tasks as needed.
  • Isolated build engines — a separate build engine compiles and tests your code; the Podman engine adds container isolation and disables network access during the build/test phases. Polyglot and auto-detected: C/C++ (CMake, Bazel), Java (Maven, Gradle), Go, Rust, Python, JavaScript/Node.
  • Built-in Git — every project has an internal Git repository; agents work in isolated Git worktrees and integrate approved changes into the development branch. External GitLab connections are supported.
  • Documents with semantic search — a structured, versioned document store (draft → archived) so design docs and requirements stay organized instead of scattered as .md files. Semantic search uses AI embeddings (VoyageAI, ranked by cosine similarity) alongside Groonga full-text keyword search.
  • Chat and MCP — plan interactively, or drive it from Claude Code and other MCP-compatible tools via the built-in MCP server.

Like tapto-code, TaptoMatic runs locally and uses your own provider API keys (Claude, OpenAI, Gemini, or a local inference server) — your code only leaves your machine to call those APIs. It's currently in preview; see taptomatic.com.

License

tapto-code is licensed under the Apache License, Version 2.0 (SPDX: Apache-2.0). See LICENSE and NOTICE. Copyright 2026 Centlake Software AB.

About

A small cross-platform CLI AI coding assistant (Claude/OpenAI/Gemini) with sandboxed file tools and an allow-listed command runner.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages