OpenCode plugin that automatically appends a correct "Assisted-by" tag to git commits
  • TypeScript 100%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Brian Clemens 59897e82e6
All checks were successful
CI / check (push) Successful in 1m10s
Publish / publish (push) Successful in 1m26s
Stage releases instead of publishing them
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.
2026-10-23 17:11:09 +09:00
.forgejo/workflows Stage releases instead of publishing them 2026-10-23 17:11:09 +09:00
src Trim docs and comments 2026-10-23 16:57:34 +09:00
test Fix plugin loading and support heredoc commits 2026-10-23 12:52:51 +09:00
.gitignore Implement the plugin 2026-10-23 12:05:05 +09:00
bun.lock Implement the plugin 2026-10-23 12:05:05 +09:00
CHANGELOG.md Trim docs and comments 2026-10-23 16:57:34 +09:00
index.ts Trim docs and comments 2026-10-23 16:57:34 +09:00
LICENSE Initial commit 2026-10-23 02:57:54 +00:00
package.json Trim docs and comments 2026-10-23 16:57:34 +09:00
README.md Stage releases instead of publishing them 2026-10-23 17:11:09 +09:00
tsconfig.json Implement the plugin 2026-10-23 12:05:05 +09:00

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:

  1. OPENCODE_GIT_TRAILER=0
  2. The current project's override, if one is set
  3. 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.ts splits 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 words git commit cannot be misread as shell.
  • src/rewrite.ts steps past wrapper prefixes (env, exec, command, nice, nohup, timeout <duration>) and VAR=value assignments, checks the command word is git or */git, walks git's global flags, and requires the subcommand to be exactly commit.
  • 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 commit subcommand matches, since resolving git ci would mean reading repo config.
  • --amend is 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's addIfDifferentNeighbor default 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 commit works, timeout --signal=KILL 10 git commit does 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 -m is.

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