- TypeScript 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
npm publish fails with E_STAGE_REQUIRED regardless of how the token is
scoped:
403 This token can only publish to a staging area.
Run `npm stage publish` to publish this version, then approve it.
Granular tokens only get staging rights, so the release step cannot be fully
automated. CI stages; approval is a separate manual step.
0.2.0 went out by hand only because the local classic token escalated through
an interactive browser auth, which is the path npm is closing.
|
||
| .forgejo/workflows | ||
| src | ||
| test | ||
| .gitignore | ||
| bun.lock | ||
| CHANGELOG.md | ||
| index.ts | ||
| LICENSE | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
opencode-git-trailer
Automatic commit attribution for opencode.
When opencode makes a git commit, it gets a trailer naming the model that made it:
Fix timeout handling in the retry path
Assisted-by: DeepSeek-V4.1-Flash-1017-GGUF
Commits you make yourself are never touched. The plugin rewrites opencode's
own shell tool calls and nothing else. It does not write to ~/.gitconfig,
install a prepare-commit-msg hook, or set commit.template.
It registers no model-facing tools. Attribution is applied outside the model's context, so it cannot be forgotten or skipped.
Requirements
- opencode 2.0+ (this is a V2 plugin; V1 plugin implementations do not run in V2 and vice versa)
- git 2.32+ for
git commit --trailer
Install
opencode plugin add @tiuxo/opencode-git-trailer
Or declare it in ~/.config/opencode/opencode.json to enable it everywhere:
{
"$schema": "https://opencode.ai/config.json",
"plugins": ["@tiuxo/opencode-git-trailer"]
}
From source
The repository is public, so it can also be installed directly, with no registry involved:
opencode plugin add 'git+https://git.tiuxo.com/brian/opencode-git-trailer.git#v0.2.1'
For a local checkout, symlink it into the global plugin directory, which is scanned automatically and needs no config entry:
ln -s /path/to/opencode-git-trailer ~/.config/opencode/plugins/git-trailer
Or declare the path explicitly. Relative paths resolve against the config file
that declares them, so use an absolute path in the global config. Note that a
bare "." is not a path; opencode reads it as a package name, so a
self-referencing entry must be written "./":
{
"plugins": ["/absolute/path/to/opencode-git-trailer"]
}
Restart the opencode service so it picks up the new plugin:
opencode service restart
opencode api get /api/plugin # look for "id":"git-trailer","state":{"status":"active"}
Locations that were already active keep their previous plugin set, so a restart is the reliable way to enable it everywhere at once.
Usage
Run /git-trailer for current status, or:
| Command | Effect |
|---|---|
/git-trailer on / off |
Enable/disable globally |
/git-trailer here on / off |
Enable/disable for the current project only |
/git-trailer here auto |
Clear this project's override, follow the global default |
/git-trailer auto |
Attribute to whichever model is active (default) |
/git-trailer set <text> |
Pin attribution to a fixed value instead of tracking the model |
/git-trailer key <Key> |
Change the trailer key (default Assisted-by) |
/git-trailer status |
Show the configuration and exactly what would be added right now |
Settings persist in opencode's plugin storage and survive restarts. Replies are delivered as synthetic messages, so changing a setting never costs a model turn.
Kill switch
OPENCODE_GIT_TRAILER=0 disables the plugin outright, overriding every stored
setting.
It has to be set on the process running the opencode server, because that is where plugins execute. Prefixing the TUI will not work when a shared background service is already running, because the variable never reaches it:
OPENCODE_GIT_TRAILER=0 opencode # no effect with a background service
OPENCODE_GIT_TRAILER=0 opencode --standalone # works: private server
OPENCODE_GIT_TRAILER=0 opencode serve # works: you own the server process
For the ordinary shared-service setup, use /git-trailer off instead.
Attribution
By default the trailer value is the active model's ID, with any org prefix and
quantization tag stripped, so
shikigami/unsloth/DeepSeek-V4.1-Flash-1017-GGUF:Q4_K_XL becomes
DeepSeek-V4.1-Flash-1017-GGUF.
The model is resolved per session from the request that issued the commit, so switching models mid-session attributes correctly, and a subagent running on a different model is attributed to its own model rather than the parent's.
Precedence
Enablement is resolved in this order, first match wins:
OPENCODE_GIT_TRAILER=0- The current project's override, if one is set
- The global default
How it works
ctx.tool.hook("execute.before") intercepts opencode's shell tool calls
before they run, and --trailer 'Key: Value' is spliced into any git commit
found in the command. The rewritten command is what you see in the TUI and
what the permission system checks, so nothing happens invisibly.
Rewriting a command you did not write is a good way to corrupt it, so the
parser is fail-open: anything it cannot handle confidently is left alone. A
missing trailer is cosmetic; a mangled git commit is not.
src/scan.tssplits the command into top-level statements and tokenizes them, tracking quote state so a;or&&inside-m "a; b"is not a separator.(...),$(...),${...}and backticks are opaque.- Heredocs are skipped rather than parsed:
<</<<-, quoted or unquoted, several queued on one line. The body is never tokenized, so a message containing&&or the wordsgit commitcannot be misread as shell. src/rewrite.tssteps past wrapper prefixes (env,exec,command,nice,nohup,timeout <duration>) andVAR=valueassignments, checks the command word isgitor*/git, walks git's global flags, and requires the subcommand to be exactlycommit.- The trailer is spliced in at an offset in the original string, so existing quoting and spacing survive byte-for-byte.
It leaves the command untouched on unterminated quotes, parens or heredocs, an
unrecognized global git flag, --help, --amend, and commits already
carrying the same trailer key.
A prepare-commit-msg hook would avoid parsing entirely, but installing one
needs core.hooksPath, which overrides the repository's own hooks and would
disable husky, lefthook or pre-commit in every repo opencode touches. Git has
no "always append this trailer" config.
Limitations
- Commits inside
(...),$(...)or backticks are not rewritten. Their contents are opaque; guessing at them risks corruption. - Git aliases are not recognized. Only the literal
commitsubcommand matches, since resolvinggit ciwould mean reading repo config. --amendis skipped, assuming the original commit already carries the trailer.-F <file>contents cannot be inspected, so a trailer already inside that file is not detected. Git'saddIfDifferentNeighbordefault usually prevents a duplicate anyway.- Unrecognized global git flags cause a skip, because guessing whether an
unknown flag consumes an argument means guessing where the subcommand
starts. Likewise for wrappers:
timeout 150 git commitworks,timeout --signal=KILL 10 git commitdoes not. - Combined short flags are not decomposed.
git commit -am "msg"is rewritten, but its message is not checked for an existing trailer the way-mis.
Development
bun install
bun test # parser, settings, attribution, and command behaviour
bun run typecheck
bun run check # both
Symlink the checkout into ~/.config/opencode/plugins/ (see
Install) to run the working copy. Edits apply to locations loaded
after the change; opencode service restart applies them everywhere.
Install it only once, because opencode rejects duplicate plugin IDs. If the same
plugin is reachable from both ~/.config/opencode/plugins/ and a plugins
entry, the second copy fails with Duplicate plugin ID: git-trailer while the
first keeps working. Check with opencode plugin list.
There is no build step: opencode loads V2 plugins from TypeScript source.
@opencode/plugin is type-only, imported with import type and erased at
runtime, so the published package has no runtime dependencies.
Releasing
Push a v* tag matching package.json. CI runs the checks, verifies the
packed tarball, and stages the release on npmjs:
npm version patch # or minor / major
git push origin main --follow-tags
Granular tokens can only stage, so approval is manual:
npm stage list
npm stage approve <stage-id>
A tag that disagrees with package.json fails the build instead of shipping.
Why index.ts is at the repository root
opencode resolves a local directory plugin by trying <dir>/server and
<dir>/index through Bun.resolveSync, without consulting package.json
main/exports. A plugin whose only entrypoint is src/index.ts resolves to
nothing and is dropped silently, with nothing logged.
Installs by package name do use exports, so the root index.ts is a
re-export and both resolution paths land on it.
License
BSD-3-Clause