Repository navigation
feat(bitwarden-beads-tools): add a plugin of tools that complement beads #284
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9bc80e1
32d0b6c
8cc9afc
4334d0d
fcdbaff
f1f6820
5cb53b9
8ac6a7f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| { | ||
| "name": "bitwarden-beads-tools", | ||
| "version": "0.1.0", | ||
| "description": "Tools that complement the beads (bd) issue tracker in Claude Code, including a live pane and status line of the project's open, in-progress, and blocked beads.", | ||
| "author": { | ||
| "name": "Bitwarden" | ||
| }, | ||
| "homepage": "https://github.com/bitwarden/ai-plugins/tree/main/plugins/bitwarden-beads-tools", | ||
| "repository": "https://github.com/bitwarden/ai-plugins", | ||
| "keywords": ["beads", "bd", "issue-tracking", "pane", "status-line"], | ||
| "types": "./types/index.d.ts" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| # Changelog | ||
|
|
||
| All notable changes to the `bitwarden-beads-tools` plugin will be documented in this file. | ||
|
|
||
| The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). | ||
|
|
||
| ## [0.1.0] - 2026-10-21 | ||
|
|
||
| ### Added | ||
|
|
||
| - New plugin of tools that complement the beads (`bd`) issue tracker in Claude Code. | ||
| - **Beads pane** tool, which keeps the project's beads in view during a session: | ||
| - **Pane**: lists open, in-progress, and blocked beads from `bd list --json`, grouped by status and sorted by priority, so the state of the work is visible without running `bd` by hand. | ||
| - **Status line**: a one-line count of beads by status, left empty when the project has nothing open. | ||
| - **`/beads-pane` command**: refreshes the list and opens the pane on demand. | ||
| - Refreshes at the start of an interactive session, every 30 seconds once a beads project is found, after any Bash tool call that runs `bd`, and when the working directory changes; stays hidden outside a beads project. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| # Bitwarden Beads Tools Plugin | ||
|
|
||
| Tools that complement the beads issue tracker in Claude Code. | ||
|
|
||
| ## Overview | ||
|
|
||
| [Beads](https://github.com/steveyegge/beads) is a git-backed issue tracker driven by the `bd` CLI. This plugin collects tools that work alongside `bd` inside a Claude Code session, so the state of the work is visible and current without leaving the session or running `bd` by hand. The tools ship as a hooks module (`hooks/register.tsx`) loaded through Claude Code's function hooks. | ||
|
|
||
| | Tool | What It Does | | ||
| | ------------------------- | ------------------------------------------------------------------------------------------------------------------ | | ||
| | [Beads pane](#beads-pane) | Shows the project's open, in-progress, and blocked beads in a pane and the status line, kept current as `bd` runs. | | ||
|
|
||
| ## Requirements | ||
|
|
||
| - The `bd` CLI from [beads](https://github.com/steveyegge/beads), version 1.3 or later, on `PATH`. The beads pane passes `--brief` to `bd list`, which earlier releases reject, so an older `bd` reads as a directory with no beads project. | ||
| - A Claude Code build with function hooks (plugins whose `hooks/hooks.json` lists `modules`). | ||
|
|
||
| ## Installation | ||
|
|
||
| ```bash | ||
| /plugin install bitwarden-beads-tools@bitwarden-marketplace | ||
| ``` | ||
|
|
||
| ## Tools | ||
|
|
||
| ### Beads pane | ||
|
|
||
| Reads the current project's open, in-progress, and blocked beads with `bd list --json` and shows them beside the conversation. | ||
|
|
||
| The pane refreshes when an interactive session starts, every 30 seconds once it has found a beads project, whenever a Bash tool call runs a `bd` command, and when the session's working directory changes. In a directory that is not a beads project, `bd list` fails and the tool stays out of the way: no pane opens, the status line is left empty, and nothing polls `bd`. | ||
|
|
||
| #### What It Shows | ||
|
|
||
| | Surface | What It Shows | | ||
| | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ||
| | Pane | A **Beads** pane grouped into In progress, Blocked, and Open, each with its count. Every row shows the priority, the bead id, and the title, sorted by priority and then id within each group. | | ||
| | Status line | A one-line count such as `beads: 1 in progress, 2 blocked, 4 open`, or `beads: error reading bd list` when its output cannot be parsed. The line stays empty when the project has nothing outstanding. | | ||
| | Command | `/beads-pane` refreshes the list and opens the pane, replying with the same count. Outside a beads project it replies that there is nothing to read. | | ||
|
|
||
| The pane opens on its own at session start when the directory is a beads project and the terminal is at least 144 columns wide, or 110 once you have opened it yourself. `/beads-pane` opens it at any width. Headless runs (`claude -p` and the SDK) skip the pane and the 30-second poll. | ||
|
|
||
| #### Usage | ||
|
|
||
| Start Claude Code in a directory that holds a beads project and the pane opens with the session. To bring it back after closing it, or to refresh it on demand: | ||
|
|
||
| ``` | ||
| /beads-pane | ||
| ``` | ||
|
|
||
| ## Development | ||
|
|
||
| The plugin's tests run under Claude Code's plugin test runner, and the manifest and hooks module are checked by the validator: | ||
|
|
||
| ```bash | ||
| claude plugin validate plugins/bitwarden-beads-tools | ||
| claude plugin test plugins/bitwarden-beads-tools | ||
| ``` | ||
|
|
||
| When the plugin is loaded from this folder with `--plugin-dir` in an interactive session, Claude Code writes its API declarations to `.claude-plugin/types/`, which `tsconfig.json` extends so `tsc -p plugins/bitwarden-beads-tools` type-checks the module. That folder is generated on each load and ignores itself. | ||
|
|
||
| ## Privacy & Security | ||
|
|
||
| Everything runs locally. The beads pane runs `bd list` in the session's working directory with a fixed argument list and a 10-second timeout, reads only the id, title, status, priority, and type of each bead, and sends nothing anywhere. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| { "modules": ["./register.tsx"] } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,250 @@ | ||
| import { atom, read, update } from "claude-code"; | ||
| import type { EngineInterface, Register } from "claude-code"; | ||
|
|
||
| import type { Bead, BeadsSnapshot, BeadStatus } from "../types"; | ||
|
|
||
| const PANE = "beads"; | ||
| const POLL_MS = 30_000; | ||
| const STATUSES: readonly BeadStatus[] = ["in_progress", "blocked", "open"]; | ||
| const LABELS: Record<BeadStatus, string> = { | ||
| in_progress: "In progress", | ||
| blocked: "Blocked", | ||
| open: "Open", | ||
| }; | ||
| const COLORS: Record<BeadStatus, "warning" | "error" | "suggestion"> = { | ||
| in_progress: "warning", | ||
| blocked: "error", | ||
| open: "suggestion", | ||
| }; | ||
|
|
||
| const snapshot = atom( | ||
| { plugin: "bitwarden-beads-tools", key: "snapshot" } as const, | ||
| null, | ||
| ); | ||
|
|
||
| // Set when an interactive session starts and cleared once the poll begins, so | ||
| // the poll waits for the first refresh that finds a beads project. | ||
| let canPoll = false; | ||
|
|
||
| // Counts refreshes, so one that a newer refresh overtook leaves the snapshot | ||
| // and status line to it. | ||
| let generation = 0; | ||
|
|
||
| // A null snapshot means the session's directory is not a beads project, so | ||
| // the pane and status line stay out of the way. A project with nothing open | ||
| // also leaves the status line empty, so the line only appears when there is | ||
| // work or an error to report. | ||
| async function refresh($: EngineInterface): Promise<BeadsSnapshot | null> { | ||
| const mine = ++generation; | ||
| const next = await load($); | ||
|
|
||
| if (mine !== generation) { | ||
| return next; | ||
| } | ||
|
|
||
| await update($, snapshot, () => next); | ||
|
|
||
| const isQuiet = | ||
| next === null || (next.error === undefined && next.beads.length === 0); | ||
| $.ui.status(isQuiet ? undefined : summary(next)); | ||
|
|
||
| if (next !== null && canPoll) { | ||
| canPoll = false; | ||
| $.clock.every(POLL_MS, () => void refresh($)); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
let isInteractive = false;
let poll: { cancel(): void } | undefined;
// in refresh, after the generation check
if (next === null) {
poll?.cancel();
poll = undefined;
} else if (isInteractive && poll === undefined) {
poll = $.clock.every(POLL_MS, () => void refresh($));
}with |
||
| } | ||
|
|
||
| return next; | ||
| } | ||
|
|
||
| async function load($: EngineInterface): Promise<BeadsSnapshot | null> { | ||
| const cwd = await $.session.cwd(); | ||
| const argv = [ | ||
| "bd", | ||
| "list", | ||
| "--json", | ||
| "--brief", | ||
| "--skip-labels", | ||
| "-n", | ||
| "0", | ||
| "--status", | ||
| STATUSES.join(","), | ||
| ]; | ||
| const ran = await $.process | ||
| .run(argv, { cwd, timeoutMs: 10_000 }) | ||
| .catch(() => null); | ||
|
|
||
| if (ran === null || ran.exitCode !== 0) { | ||
|
SaintPatrck marked this conversation as resolved.
Resolved
|
||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| const parsed: unknown = JSON.parse(ran.stdout || "{}"); | ||
|
SaintPatrck marked this conversation as resolved.
Resolved
|
||
| const rows = (parsed as { issues?: unknown } | null)?.issues; | ||
|
|
||
| if (!Array.isArray(rows)) { | ||
| return { beads: [], error: "bd list returned an unexpected shape" }; | ||
| } | ||
|
|
||
| return { beads: rows.map(toBead).filter(isBead) }; | ||
| } catch { | ||
| return { beads: [], error: "bd list returned output that is not JSON" }; | ||
| } | ||
| } | ||
|
|
||
| function toBead(row: unknown): Bead | null { | ||
| if (typeof row !== "object" || row === null) { | ||
| return null; | ||
| } | ||
|
|
||
| const { id, title, status, priority, issue_type } = row as Record< | ||
| string, | ||
| unknown | ||
| >; | ||
|
|
||
| if (typeof id !== "string" || !STATUSES.includes(status as BeadStatus)) { | ||
| return null; | ||
| } | ||
|
|
||
| return { | ||
| id, | ||
| title: typeof title === "string" ? title : "", | ||
| status: status as BeadStatus, | ||
| priority: typeof priority === "number" ? priority : 4, | ||
| type: typeof issue_type === "string" ? issue_type : "task", | ||
| }; | ||
| } | ||
|
|
||
| function isBead(bead: Bead | null): bead is Bead { | ||
| return bead !== null; | ||
| } | ||
|
|
||
| export function summary({ beads, error }: BeadsSnapshot): string { | ||
| if (error !== undefined) { | ||
| return "beads: error reading bd list"; | ||
| } | ||
|
|
||
| const counts = STATUSES.map((status) => { | ||
| const count = beads.filter((bead) => bead.status === status).length; | ||
|
|
||
| return [status, count] as const; | ||
| }); | ||
| const parts = counts | ||
| .filter(([, n]) => n > 0) | ||
| .map(([status, n]) => `${n} ${LABELS[status].toLowerCase()}`); | ||
|
|
||
| return `beads: ${parts.length === 0 ? "none open" : parts.join(", ")}`; | ||
| } | ||
|
|
||
| function clip(text: string, width: number): string { | ||
| return text.length <= width | ||
| ? text | ||
| : text.slice(0, Math.max(0, width - 3)) + "..."; | ||
| } | ||
|
|
||
| export const register: Register = (on) => { | ||
| on("session.start", async ($, e, next) => { | ||
| await $.command.register({ | ||
| name: "beads-pane", | ||
| description: "Show this project's beads in a pane", | ||
| }); | ||
|
|
||
| if (e.isInteractive) { | ||
| canPoll = true; | ||
| void refresh($).then((found) => { | ||
| if (found !== null) { | ||
| void $.ui.open({ id: PANE, title: "Beads" }); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| return next(e); | ||
| }); | ||
|
|
||
| // /clear, /resume, and /branch reset the snapshot to null without firing | ||
| // session.start again, so read the beads again for the reset session. | ||
| on( | ||
| "classic.SessionStart", | ||
| { source: ["clear", "resume", "fork"] }, | ||
| async ($, e, next) => { | ||
| void refresh($); | ||
|
|
||
| return next(e); | ||
| }, | ||
| ); | ||
|
|
||
| on("command.run", { command: "beads-pane" }, async ($) => { | ||
| const found = await refresh($); | ||
|
|
||
| if (found === null) { | ||
| return { text: "No beads project here (bd list found nothing to read)." }; | ||
| } | ||
|
|
||
| await $.ui.open({ id: PANE, title: "Beads" }); | ||
|
|
||
| return { text: `Beads pane opened. ${summary(found)}` }; | ||
| }); | ||
|
|
||
| on("tool.call", { tool: "Bash" }, async ($, e, next) => { | ||
| const ran = await next(e); | ||
|
|
||
| if (/(^|[\s;&|(])bd\s/.test(e.command)) { | ||
| void refresh($); | ||
| } | ||
|
|
||
| return ran; | ||
| }); | ||
|
|
||
| on("classic.CwdChanged", async ($, e, next) => { | ||
| const ran = await next(e); | ||
| void refresh($); | ||
|
|
||
| return ran; | ||
| }); | ||
|
|
||
| on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => { | ||
| const { Box, Text } = $.ui.resolve(e); | ||
| const current = await read($, snapshot); | ||
| const width = Math.max(20, e.props.bodyColumns); | ||
|
|
||
| if (current === null) { | ||
| return <Text dimColor>No beads project in this directory.</Text>; | ||
| } | ||
|
|
||
| if (current.error !== undefined) { | ||
| return <Text color="error">{current.error}</Text>; | ||
| } | ||
|
|
||
| if (current.beads.length === 0) { | ||
| return <Text dimColor>No open beads.</Text>; | ||
| } | ||
|
|
||
| const groups = STATUSES.map((status) => ({ | ||
| status, | ||
| beads: current.beads | ||
| .filter((bead) => bead.status === status) | ||
| .sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id)), | ||
| })).filter((group) => group.beads.length > 0); | ||
|
|
||
| return ( | ||
| <Box flexDirection="column"> | ||
| {groups.map((group) => ( | ||
| <Box key={group.status} flexDirection="column" marginBottom={1}> | ||
| <Text bold color={COLORS[group.status]}> | ||
| {LABELS[group.status]} ({group.beads.length}) | ||
| </Text> | ||
| {group.beads.map((bead) => { | ||
| const head = `P${bead.priority} ${bead.id} `; | ||
|
|
||
| return ( | ||
| <Text key={bead.id}> | ||
| <Text dimColor>{head}</Text> | ||
| {clip(bead.title, width - head.length)} | ||
| </Text> | ||
| ); | ||
| })} | ||
| </Box> | ||
| ))} | ||
| </Box> | ||
| ); | ||
| }); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
isQuietcondition combines||and&&on one line, requiring readers to track multiple logical sequences.Show fix
Details
β¨ AI Reasoning
βThe
isQuietexpression combines an OR with a grouped AND on the same line. Readers must track how those conditions interact to determine whether the snapshot is quiet, making this more cognitively demanding than a single logical sequence.Reply on this comment to give feedback or ignore the issue.
More info
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parenthesized grouping already reads as one rule: no project, or a project with nothing open and no error. Splitting it repeats the null guard twice without making that clearer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
β You don't have permission to ignore issues.