Skip to main content

Plugins

Add your own commands to rt. A plugin is a folder under ~/.rt/plugins/<name>/ with a declarative plugin.json manifest and either TypeScript files (run in-process with an injected API) or existing executables (run as subprocesses). Plugin commands live in the root command tree right next to built-ins and inherit everything the tree provides: fzf navigation, breadcrumbs, repo/worktree context resolution, alt-enter arg forms, invocation logging, and error capture.

Two properties worth knowing up front:

  • A broken plugin can never break rt. Discovery parses JSON and validates structure without executing any plugin code; a malformed plugin is skipped with a warning naming it and why. Plugin code only loads when its own command runs.
  • No build step. rt's embedded Bun runtime transpiles your .ts files on import. Edit the file, run the command.

Quickstart

rt plugin new my-tool # scaffold ~/.rt/plugins/my-tool/ + IDE types, runs bun install
rt my-tool # run it
rt plugin list # installed plugins and their health
rt plugin validate # deep checks: files exist, modules import, exports match

rt plugin new leaves you with a working command. Open ~/.rt/plugins/my-tool/ in your editor and you get full autocomplete on the injected API.

See the full command reference: rt plugin, rt plugin new, rt plugin list, rt plugin validate.

Plugin anatomy

~/.rt/plugins/my-tool/
plugin.json # manifest (required): declares your commands
my-tool.ts # handler modules referenced by the manifest
helper.ts # relative imports between plugin files work
scripts/deploy.sh # exec targets referenced by the manifest
package.json # dev-time only: types for your IDE
tsconfig.json # dev-time only
node_modules/ # dev-time only (bun install)

rt reads plugin.json and your handler files at runtime. package.json, tsconfig.json, and node_modules exist purely so your editor is happy; rt never reads them.

State lives outside the plugin folder:

  • ~/.rt/plugin-data/<name>/ scoped JSON storage (created on first write)
  • ~/.rt/logs/plugins.YYYY-MM-DD.log your domain events, visible in rt daemon logs

The manifest

{
"name": "my-tool",
"apiVersion": 1,
"commands": {
"standup": {
"description": "Draft my standup",
"module": "./standup.ts",
"aliases": ["su"],
"context": "worktree"
},
"notes": {
"description": "Scratch notes",
"subcommands": {
"add": { "description": "Add a note", "module": "./notes.ts", "fn": "add" },
"list": { "description": "List notes", "module": "./notes.ts", "fn": "list" }
}
},
"deploy": {
"description": "Run my deploy script",
"exec": "./scripts/deploy.sh",
"context": "repo"
}
}
}

Top-level fields: name (kebab-case, should match the folder), apiVersion (must be 1), optional description, and commands. Unknown fields anywhere are validation errors, so typos fail loudly instead of being ignored.

Each command node takes:

FieldTypeNotes
descriptionstringrequired; shown in pickers and usage
modulestringrelative path to a .ts/.tsx file
fnstringexport to call, default "run"; only with module
execstring or string[]executable to spawn; array form is [cmd, ...fixedArgs]
subcommandsobjectnested nodes, same shape
aliasesstring[]kebab-case; e.g. ["su"]
hiddenbooleanrunnable but not shown in pickers/usage
context"repo" or "worktree"rt resolves identity before your handler runs
requiresTTYbooleanrefuse to run outside an interactive terminal
fullscreenbooleanskip the breadcrumb header; command owns the screen
argsarraydeclared args; alt-enter at the picker opens a form (fields: name, flag, type of text/boolean/select, hint, placeholder, default, options)

Every node needs description plus exactly one of module, exec, or subcommands. Command names and aliases are kebab-case.

TypeScript commands (module)

Your handler signature:

import type { RtCommandContext } from "rt-plugin";

export async function run(args: string[], ctx: RtCommandContext) {
const team = await ctx.rt.pick(["cv", "platform"], { label: "team" });
if (!team) return;

const store = ctx.rt.store<string[]>("history");
const history = (await store.get()) ?? [];
history.push(team);
await store.set(history);

ctx.rt.log.info("ran", { team, repo: ctx.identity?.repoName });
}

args is everything after your command name on the command line. ctx carries:

  • ctx.identity (when the node declares context): { repoName, repoRoot, dataDir, remoteUrl, baseUrl }. With "context": "worktree" rt auto-detects from cwd or shows its repo/worktree picker first, exactly like built-in commands. ctx.autoResolved is true when it came from cwd without user interaction.
  • ctx.rt, the injected API:
MethodBehavior
pick(items, opts?)rt's fuzzy palette. Items are strings or { value, label?, hint? }. Resolves null on Esc.
prompt(label, opts?)single-line text input (placeholder, default)
confirm(label, opts?)yes/no, default picks the preselected answer
store<T>(key)get()/set(v) JSON persistence at ~/.rt/plugin-data/<plugin>/<key>.json; keys are letters, digits, ., _, -
log.info/warn/debug(msg, data?)JSON lines to ~/.rt/logs/plugins.YYYY-MM-DD.log; debug only writes when RT_LOG_LEVEL=debug

The runtime contract is the standard library (Bun/Node builtins) plus this API. npm dependencies in plugin runtime code are not supported in v1. You never need to log invocations, durations, or errors yourself; the dispatcher records those for every command in ~/.rt/logs/cli.YYYY-MM-DD.log.

Throwing is fine: the error and stack land in the CLI log with your command's name, and rt exits 1.

Executable commands (exec)

For mounting existing scripts with zero TypeScript. The target runs with inherited stdio (interactive programs and TUIs work), your invocation args appended after any fixed args, and cwd wherever rt resolved to (so context still matters). If the spec contains a / it resolves relative to the plugin folder; a bare name uses PATH.

Environment passed to the child:

VariableWhen
RT_PLUGIN_NAME, RT_PLUGIN_DATA_DIRalways
RT_REPO_NAME, RT_REPO_ROOT, RT_REPO_DATA_DIR, RT_REMOTE_URL, RT_BASE_URL, RT_AUTO_RESOLVEDwhen the node declares context

A non-zero child exit becomes rt's own exit code, logged as an error outcome at the seam. Exec nodes do not get the injected API; that is the module/exec trade-off.

Collisions and reserved names

Plugin commands merge into the root tree on every invocation. Built-ins always win, including their aliases and reserved entry points like verify; the losing plugin command is not mounted and a warning names both sides. Between plugins, the first by directory sort order wins. A clean setup prints nothing.

IDE setup

rt writes a types-only package to ~/.rt/plugin-api/ (regenerated automatically whenever the embedded contract changes, e.g. after an rt upgrade). The scaffolded package.json depends on it via "rt-plugin": "file:../../plugin-api", which is why import type { RtCommandContext } from "rt-plugin" resolves in your editor after bun install. The import is type-only and erased at runtime; if you accidentally value-import rt-plugin, it throws with a message explaining the API arrives via ctx.

If your editor shows red squiggles in a plugin folder, run bun install in that folder. Typecheck a plugin any time with bunx tsc --noEmit from its folder.

Letting an agent build your plugin

The rt repo ships an agent skill at skills/rt-create-plugin/ that teaches a coding agent the full authoring loop (scaffold, manifest, handlers, validate, run). Symlink or copy it into your skills directory (~/.claude/skills/ for Claude Code, ~/.cursor/skills/ for Cursor, or wherever your harness discovers skills), then ask your agent for the command you want, e.g. "create an rt plugin that adds a standup command".

Trust model

Plugin code runs in-process with rt's privileges. This is personal tooling for your own machine: only install plugin directories you trust, and read third-party plugin code before installing it, because installing it is agreeing to run it.

Troubleshooting

SymptomCause
skipping plugin "x": plugin.json is unreadable or not valid JSONbroken manifest; fix the JSON, rt is unaffected meanwhile
skipping plugin "x": <node>: unknown field "modle"manifest typo; unknown fields are rejected by design
plugin x: ./cmd.ts does not export "run"export the function the manifest names (fn, default run)
command missing from rt --helpcollision (see the warning) or hidden: true
rt plugin validate fails on a moduleit dry-imports your file; top-level errors surface here before you hit them at runtime
editor cannot find rt-pluginrun bun install in the plugin folder