Contributing
August 25, 2026 · View on GitHub
tapesctl is the command-line client for Tapes: it runs a
coding-agent harness under a capture proxy, ships the captured turns to a tapes
server, and provides a command line over the data model that comes back. See
README.md for what the tool does from a user's side.
This file is the orientation a contributor — or a contributor's coding agent — needs before the first change.
Build and test
The Nix flake dev shell pins the Rust toolchain (via rust-toolchain.toml;
stable, edition 2024, minimum 1.85). It is the recommended environment, but
nothing here requires it — a matching stable toolchain works.
nix develop
make build # cargo build --workspace
make run ARGS=version # cargo run -p tapesctl -- version
Before opening a pull request:
make lint # cargo fmt --all --check + cargo clippy --workspace --all-targets -D warnings
make test # cargo test --workspace
make check runs build, clippy, and test together. make help lists every
target.
Reproducing CI locally
CI runs through Dagger, so the PR gates reproduce on your machine. This needs a container engine, and is slower than the cargo-native targets — use it when you want to reproduce a CI failure, not for ordinary iteration.
make ci # dagger call lint + test — the same two gates CI runs
make dist # cross-compile all four release targets into ./build
CI additionally cross-compiles for linux/{amd64,arm64} and
darwin/{amd64,arm64} and smoke-tests each binary: tapesctl version must
print its canary line and the commit it was built from, and a bare tapesctl
must print help and exit 2.
What a build calls itself
tapesctl version and tapesctl --version print the same three fields the
tapes server prints — version, commit, build date — and the version command
adds the canary after them.
None of it comes from Cargo.toml. The workspace version is a placeholder that
no release bumps, because a release tag is created by tagging a commit that has
already merged, so the source cannot know it. The build supplies the identity
instead: a local cargo build fills the commit in from git and reports a
development version.
tapesctl 0.0.0-dev+3f2a1b9
Sha: 3f2a1b9c0d4e5f60718293a4b5c6d7e8f9012345
Built at: unknown
The release and nightly workflows pass the rest to the Dagger build, which
exports TAPESCTL_RELEASE_TAG, TAPESCTL_BUILD_SHA, and TAPESCTL_BUILD_DATE
into the compile. Any of the three can be set by hand to see what a release
will print:
TAPESCTL_RELEASE_TAG=v9.9.9 cargo build -p tapesctl
Layout
The workspace has exactly one member, crates/tapesctl. (Shared client-side
harness knowledge — launch recipes, session attribution, transcript discovery,
and the capture envelope — lives in a separate repository,
tapes-crates, and is
consumed here as a revision-pinned dependency.)
Inside crates/tapesctl/src:
cli.rs— the clap surface: every command, flag, and help string.machine.rs— the crate's one ambient read of the environment.config.rs—~/.tapes/config.toml, the persisted default server.start/— the just-in-time capture proxy (the wire lane).transcript/— the transcript lane: live tailer and thesyncsweep.codex_app/— plugin install and the capture proxy for a harness that launches itself.api/— the<resource> <method>read client.cassette/— the runtime-discoveredcassettes <name> <method>surface: discovery, the spec reducer, the cache, and clap synthesis.upgrade/— verified, crash-safe self-replacement: bucket resolution, staging, digest verification, and the atomic swap.install_layout.rs— where this binary actually lives, derived from the running executable.upgradeanduninstallboth build on it.rc_block.rs— removal of the installer's sentinel block from shell rc files. Its markers are pinned againstinstall.shby a test.uninstall.rs— the binary, the local state, and that block.plugin.rs,capture.rs,logging.rs,error.rs— the remaining command entry points and cross-cutting support.ports/— search, skills, and seed.
Conventions
No unwrap, expect, or panic in library code. The workspace denies all
three via [workspace.lints.clippy]; return Result and surface errors through
the crate error types. Test modules opt out explicitly, and that attribute is
the marker for "this is test code":
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests { /* ... */ }
Formatting is enforced: rustfmt.toml sets max_width = 100. Run
make fmt before make lint.
Help text is user-facing documentation. Flags and subcommands carry long
help that the README quotes; if you change behavior, change the doc comment in
cli.rs in the same commit.
Traps worth knowing
These are the mistakes that are easy to make and slow to diagnose.
Ambient environment reads belong at the CLI boundary
Machine::resolve() is the crate's one read of the real environment (home
directory, ~/.codex/config.toml, the codex program). It happens at the CLI
boundary; every function beneath it takes an explicit path, and
Machine::at(..) / with_codex_program(..) / with_tapes_config_path(..)
construct one pointing anywhere you like.
Keep it that way. This is not hypothetical: the install tests once spawned
whichever codex was on the developer's PATH, and that CLI wrote a
registration into their real ~/.codex/config.toml pointing at a temp directory
the test was about to delete. The suite broke the machine it ran on, and the
damage outlived the run.
Machine::resolve therefore panics under #[cfg(test)] rather than reading the
real environment. Build a Machine::at(..) over a tempfile::tempdir()
instead, and use with_codex_program(..) / with_tapes_config_path(..) to aim
the rest.
Two things that guard does not cover, so check them by hand:
- Integration tests in
crates/tapesctl/tests/link the library withoutcfg(test), so the panic never fires there. They must isolate themselves — the existing ones spawn the binary with an overriddenHOMEand clear theTAPES_*variables. TAPESCTL_CACHE_DIR, if a test exercising the cassette cache does not set it, falls back to the developer's real cache directory. Cosmetic rather than destructive, but still a write outside the tempdir.
There is deliberately no environment-variable override for the config path: environment variables are process-global while tests are not. Passing the value makes an escape a compile error instead. Don't add one.
When you need to see what an install would do, --dry-run on
tapesctl plugin install / uninstall reports every path and writes nothing.
pi needs both --provider and --model, or it captures nothing
This one fails silently, which is what makes it worth writing down.
--provider and --model are pi's own flags, passed through after --;
tapesctl passes no argv of its own to pi. pi only takes the pair — its
initial-model resolution is gated on cliProvider && cliModel. Given just one,
it ignores that flag and falls through to the saved default, then to the first
provider it finds a key for. That can land on a provider the capture does not
cover, and then the session runs normally and records nothing.
tapesctl plugin install pi # once; writes pi's capture extension
tapesctl start pi --ingest-url http://localhost:8082 -- --provider anthropic --model <model-id>
There is no tapesctl error for this — do not go looking for one to improve.
The observable symptom is a completed session with no captured turns. When
triaging "pi captured nothing", check the provider/model pair before anything
else. (pi does warn from inside the harness when the selected model's provider
is not covered.)
Also note tapesctl start pi with no --upstream routes each of pi's
Anthropic, OpenAI, and OpenAI Codex providers to its own upstream, so all three
are captured; an explicit --upstream collapses that to one.
--schema applies to some harnesses and is an error on the rest
tapesctl start accepts claude, codex, and pi. --schema picks which
upstream API schema the proxy fronts, and it is only meaningful for a harness
that redirects several providers to one endpoint — of start's three, that is
pi, which defaults to anthropic because that is the provider it ships
selected. A harness that speaks exactly one schema takes it from the harness,
and passing --schema there is a hard error, deliberately, rather than a silent
no-op:
$ tapesctl start claude --schema openai
tapesctl: --schema does not apply to claude, which speaks anthropic only (it is
for a harness that redirects several providers to one endpoint, such as pi)
The harness registry is wider than what start accepts
Three surfaces, three different sets — don't unify them:
- the shared registry (in the
tapes-cratesdependency) knowsclaude,codex,codex-app,opencode, andpi, plus the aliasesclaude-codeandcodex-desktop; name matching is case-insensitive and trims whitespace; plugin installaccepts the whole registry deliberately, so a harness that gains a plugin upstream becomes installable here without this repo being edited;startaccepts onlyclaude,codex, andpi.
opencode is withdrawn from start, not missing from it: it keeps its
registry entry, its plugin, and its arms in start/, because on the OAuth path
its plugin captures nothing, and a start that runs an agent while recording
none of it is worse than one that refuses. Those arms are pending work, not dead
code — resist tidying them away, and reinstating the verb is moving one entry
back into SUPPORTED in start/mod.rs.
Codex desktop-app trust lives in the codex CLI
tapesctl plugin install codex-app registers a hook plugin and repoints
~/.codex/config.toml, but it cannot grant the plugin's hooks trust. The user
must run /hooks in the codex CLI — the desktop app has no /hooks
command and its Hooks settings page does not list plugin hooks, but trust is
shared state, so trusting once in the CLI covers the app. Trust binds to the
exact hook-definition hash, so any reinstall requires trusting again. Don't
document or automate this as an in-app step; it isn't one.
start swallows unknown flags into the harness
harness_args is trailing_var_arg = true, allow_hyphen_values = true, so
anything after the harness name that tapesctl does not recognize is passed
through verbatim. That is the point — but it means a misspelled tapesctl flag
after the harness name is silently handed to the harness instead of rejected.
Put tapesctl flags before the harness name, or -- before the harness's own:
tapesctl -v start claude --ingest-url http://localhost:8082 -- --model opus
Vendored corpora and contracts are byte-for-byte copies
crates/tapesctl/contracts/ and crates/tapesctl/vendor/*/ are vendored from
published upstream artifacts and are not hand-editable. The fixture corpora
are sealed by a DIGEST that this repo's own suite recomputes, so an edited
case fails here rather than quietly making a red test green. Refresh them with
the scripts/sync-*.sh scripts, and check a contract pin with
make contracts-check. A change to the shared behavior belongs upstream first.
The cassette surface is discovered, not compiled in
Which cassettes exist is deployment configuration, so tapesctl builds those
commands at runtime from the server's OpenAPI documents and caches the result
per server. Never add a hard-coded cassette list — it would freeze one
deployment's extensions into everyone's binary. Point the cache elsewhere with
TAPESCTL_CACHE_DIR.
Local endpoint defaults
Read commands default to http://localhost:8081; capture commands default to
http://localhost:8082. Keep their configuration separate: --api-url /
TAPES_API_URL for reads and --ingest-url / TAPES_INGEST_URL for capture.
Pull requests
Pull request titles must use one of the repository's accepted conventional
prefixes — for example :sparkles: feat:, :wrench: fix:, :broom: chore:,
:recycle: refactor:, or :books: docs: — optionally scoped, as in
:wrench: fix(start): ....
A separate check looks for a maintainers' issue-tracker reference. It is not expected on a pull request from a fork: that tracker is not readable from outside the organization, so the check reports as not-applicable and maintainers link the issue when they triage. Fork pull requests run the full lint, test, build, and smoke matrix on GitHub-hosted runners.
By contributing you agree that your contribution is dual-licensed under MIT and Apache-2.0, matching the license on this repository.