opencode-beans 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ import { execFile } from "node:child_process"
2
+ import { promisify } from "node:util"
3
+ import type { Stats } from "./layout.ts"
4
+
5
+ const run = promisify(execFile)
6
+ const MAX_BUFFER = 8 * 1024 * 1024
7
+
8
+ export const DEFAULT_TIMEOUT_MS = 5_000
9
+
10
+ export interface LoadOptions {
11
+ readonly signal?: AbortSignal
12
+ readonly timeoutMs?: number
13
+ }
14
+
15
+ export interface ExecOptions {
16
+ readonly cwd: string
17
+ readonly maxBuffer: number
18
+ readonly timeout: number
19
+ readonly signal?: AbortSignal
20
+ }
21
+
22
+ export interface ExecResult {
23
+ readonly stdout: string
24
+ }
25
+
26
+ export type ExecFile = (
27
+ command: string,
28
+ args: readonly string[],
29
+ options: ExecOptions,
30
+ ) => Promise<ExecResult>
31
+
32
+ export interface BeanRecord {
33
+ readonly status: string
34
+ readonly type?: string
35
+ }
36
+
37
+ export class BeansUnavailableError extends Error {}
38
+
39
+ export function parseBeans(raw: string): BeanRecord[] {
40
+ let parsed: unknown
41
+ try {
42
+ parsed = JSON.parse(raw)
43
+ } catch {
44
+ throw new BeansUnavailableError("beans list --json did not return JSON")
45
+ }
46
+ if (!Array.isArray(parsed)) {
47
+ throw new BeansUnavailableError("beans list --json did not return an array")
48
+ }
49
+ return parsed.map((entry) => {
50
+ const record = entry as { status?: unknown; type?: unknown } | null
51
+ const status = record?.status
52
+ if (typeof status !== "string") {
53
+ throw new BeansUnavailableError("a bean is missing its status")
54
+ }
55
+ const type = record?.type
56
+ return typeof type === "string" ? { status, type } : { status }
57
+ })
58
+ }
59
+
60
+ export function aggregate(
61
+ beans: readonly BeanRecord[],
62
+ ready?: number,
63
+ blocked?: number,
64
+ ): Stats {
65
+ const counts = new Map<string, number>()
66
+ const types = new Map<string, number>()
67
+ for (const bean of beans) {
68
+ counts.set(bean.status, (counts.get(bean.status) ?? 0) + 1)
69
+ if (bean.type !== undefined) {
70
+ types.set(bean.type, (types.get(bean.type) ?? 0) + 1)
71
+ }
72
+ }
73
+ return {
74
+ total: beans.length,
75
+ byStatus: [...counts].map(([status, count]) => ({ status, count })),
76
+ byType: [...types].map(([type, count]) => ({ type, count })),
77
+ ...(ready === undefined ? {} : { ready }),
78
+ ...(blocked === undefined ? {} : { blocked }),
79
+ }
80
+ }
81
+
82
+ export interface BeanReader {
83
+ loadStats(dir: string, options?: LoadOptions): Promise<Stats>
84
+ }
85
+
86
+ export function createBeanReader(exec: ExecFile): BeanReader {
87
+ const list = async (
88
+ dir: string,
89
+ args: string[],
90
+ options: LoadOptions,
91
+ ): Promise<BeanRecord[]> => {
92
+ const { stdout } = await exec("beans", args, {
93
+ cwd: dir,
94
+ maxBuffer: MAX_BUFFER,
95
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
96
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
97
+ })
98
+ return parseBeans(stdout)
99
+ }
100
+
101
+ const count = async (
102
+ dir: string,
103
+ args: string[],
104
+ options: LoadOptions,
105
+ ): Promise<number | undefined> => {
106
+ try {
107
+ return (await list(dir, args, options)).length
108
+ } catch {
109
+ return undefined
110
+ }
111
+ }
112
+
113
+ return {
114
+ async loadStats(dir: string, options: LoadOptions = {}): Promise<Stats> {
115
+ let beans: BeanRecord[]
116
+ try {
117
+ beans = await list(dir, ["list", "--json"], options)
118
+ } catch {
119
+ throw new BeansUnavailableError("beans list --json failed")
120
+ }
121
+ const ready = await count(dir, ["list", "--ready", "--json"], options)
122
+ const blocked = await count(dir, ["list", "--is-blocked", "--json"], options)
123
+ return aggregate(beans, ready, blocked)
124
+ },
125
+ }
126
+ }
127
+
128
+ const defaultExec: ExecFile = async (command, args, options) => {
129
+ const { stdout } = await run(command, [...args], { ...options })
130
+ return { stdout }
131
+ }
132
+
133
+ export const { loadStats } = createBeanReader(defaultExec)
@@ -0,0 +1,104 @@
1
+ import type { Stats, StatusCount, TypeCount } from "./layout.ts"
2
+
3
+ export const CACHE_KEY = "beans.stats.cache"
4
+
5
+ export interface CacheState {
6
+ [directory: string]: Stats
7
+ }
8
+
9
+ function decodeCount(entry: unknown): StatusCount | null {
10
+ if (typeof entry !== "object" || entry === null) {
11
+ return null
12
+ }
13
+ const record = entry as { status?: unknown; count?: unknown }
14
+ if (typeof record.status !== "string" || typeof record.count !== "number") {
15
+ return null
16
+ }
17
+ return { status: record.status, count: record.count }
18
+ }
19
+
20
+ function decodeType(entry: unknown): TypeCount | null {
21
+ if (typeof entry !== "object" || entry === null) {
22
+ return null
23
+ }
24
+ const record = entry as { type?: unknown; count?: unknown }
25
+ if (typeof record.type !== "string" || typeof record.count !== "number") {
26
+ return null
27
+ }
28
+ return { type: record.type, count: record.count }
29
+ }
30
+
31
+ export function decodeStats(raw: unknown): Stats | null {
32
+ if (typeof raw !== "object" || raw === null) {
33
+ return null
34
+ }
35
+ const record = raw as {
36
+ total?: unknown
37
+ byStatus?: unknown
38
+ byType?: unknown
39
+ ready?: unknown
40
+ blocked?: unknown
41
+ }
42
+ if (typeof record.total !== "number" || !Array.isArray(record.byStatus)) {
43
+ return null
44
+ }
45
+ const byStatus: StatusCount[] = []
46
+ for (const entry of record.byStatus) {
47
+ const decoded = decodeCount(entry)
48
+ if (decoded === null) {
49
+ return null
50
+ }
51
+ byStatus.push(decoded)
52
+ }
53
+ const byType: TypeCount[] = []
54
+ if (record.byType !== undefined) {
55
+ if (!Array.isArray(record.byType)) {
56
+ return null
57
+ }
58
+ for (const entry of record.byType) {
59
+ const decoded = decodeType(entry)
60
+ if (decoded === null) {
61
+ return null
62
+ }
63
+ byType.push(decoded)
64
+ }
65
+ }
66
+ return {
67
+ total: record.total,
68
+ byStatus,
69
+ byType,
70
+ ...(typeof record.ready === "number" ? { ready: record.ready } : {}),
71
+ ...(typeof record.blocked === "number" ? { blocked: record.blocked } : {}),
72
+ }
73
+ }
74
+
75
+ export function toSnapshot(stats: Stats): Stats {
76
+ return {
77
+ total: stats.total,
78
+ byStatus: stats.byStatus.map((entry) => ({ status: entry.status, count: entry.count })),
79
+ byType: (stats.byType ?? []).map((entry) => ({ type: entry.type, count: entry.count })),
80
+ ...(stats.ready === undefined ? {} : { ready: stats.ready }),
81
+ ...(stats.blocked === undefined ? {} : { blocked: stats.blocked }),
82
+ }
83
+ }
84
+
85
+ export interface CachePort {
86
+ read(directory: string): unknown
87
+ write(directory: string, stats: Stats): void
88
+ }
89
+
90
+ export interface Cache {
91
+ seed(): Stats | null
92
+ save(stats: Stats): void
93
+ }
94
+
95
+ export function createCache(port: CachePort, directory: string): Cache {
96
+ return {
97
+ seed() {
98
+ return decodeStats(port.read(directory))
99
+ },
100
+ save(stats) {
101
+ port.write(directory, toSnapshot(stats))
102
+ },
103
+ }
104
+ }
@@ -0,0 +1,6 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+
3
+ export default Plugin.define({
4
+ id: "beans.stats",
5
+ setup() {},
6
+ })
@@ -0,0 +1,136 @@
1
+ export interface StatusCount {
2
+ readonly status: string
3
+ readonly count: number
4
+ }
5
+
6
+ export interface TypeCount {
7
+ readonly type: string
8
+ readonly count: number
9
+ }
10
+
11
+ export interface Stats {
12
+ readonly total: number
13
+ readonly byStatus: readonly StatusCount[]
14
+ readonly byType?: readonly TypeCount[]
15
+ readonly ready?: number
16
+ readonly blocked?: number
17
+ }
18
+
19
+ export interface SummaryLine {
20
+ readonly label: string
21
+ readonly count: number
22
+ }
23
+
24
+ export interface SectionLayout {
25
+ readonly total: string
26
+ readonly body: readonly string[]
27
+ }
28
+
29
+ interface Cell {
30
+ readonly label: string
31
+ readonly count: number
32
+ readonly bar?: string
33
+ }
34
+
35
+ interface ColumnWidths {
36
+ readonly label: number
37
+ readonly count: number
38
+ }
39
+
40
+ const BAR_WIDTH = 4
41
+ const FULL_BLOCK = "█"
42
+ const HALF_BLOCK = "▌"
43
+ const LABEL_GUTTER = 2
44
+ const COUNT_GUTTER = 2
45
+ const BAR_GAP = " "
46
+
47
+ export function statusRows(stats: Stats): StatusCount[] {
48
+ return [...stats.byStatus].sort(
49
+ (left, right) => right.count - left.count || left.status.localeCompare(right.status),
50
+ )
51
+ }
52
+
53
+ export function typeRows(stats: Stats): TypeCount[] {
54
+ return [...(stats.byType ?? [])].sort(
55
+ (left, right) => right.count - left.count || left.type.localeCompare(right.type),
56
+ )
57
+ }
58
+
59
+ export function bars<T extends { readonly count: number }>(
60
+ entries: readonly T[],
61
+ width: number = BAR_WIDTH,
62
+ ): Array<T & { readonly bar: string }> {
63
+ const max = entries.reduce((largest, entry) => Math.max(largest, entry.count), 0)
64
+ return entries.map((entry) => ({ ...entry, bar: bar(entry.count, max, width) }))
65
+ }
66
+
67
+ export function bar(count: number, max: number, width: number = BAR_WIDTH): string {
68
+ if (max <= 0 || count <= 0) {
69
+ return ""
70
+ }
71
+ const steps = Math.min(width * 2, Math.round((count / max) * width * 2))
72
+ const full = Math.floor(steps / 2)
73
+ const half = steps % 2 === 1
74
+ return FULL_BLOCK.repeat(full) + (half ? HALF_BLOCK : "")
75
+ }
76
+
77
+ export function totalLabel(stats: Stats): string {
78
+ return `${stats.total} total`
79
+ }
80
+
81
+ export function layoutSection(stats: Stats): SectionLayout {
82
+ return layout(stats, false)
83
+ }
84
+
85
+ export function layoutBreakdown(stats: Stats): SectionLayout {
86
+ return layout(stats, true)
87
+ }
88
+
89
+ function layout(stats: Stats, withTypes: boolean): SectionLayout {
90
+ const statuses = bars(statusRows(stats)).map((row) => ({
91
+ label: row.status,
92
+ count: row.count,
93
+ bar: row.bar,
94
+ }))
95
+ const summary = summaryLines(stats).map((line) => ({ label: line.label, count: line.count }))
96
+ const types = bars(typeRows(stats)).map((row) => ({ label: row.type, count: row.count, bar: row.bar }))
97
+ const widths = columnWidths(withTypes ? [...statuses, ...summary, ...types] : [...statuses, ...summary])
98
+
99
+ const body: string[] = []
100
+ appendBlock(body, statuses, widths)
101
+ appendBlock(body, summary, widths)
102
+ if (withTypes) {
103
+ appendBlock(body, types, widths)
104
+ }
105
+ return { total: totalLabel(stats), body }
106
+ }
107
+
108
+ function appendBlock(body: string[], cells: readonly Cell[], widths: ColumnWidths): void {
109
+ if (cells.length === 0) {
110
+ return
111
+ }
112
+ body.push("", ...cells.map((cell) => renderCell(cell, widths)))
113
+ }
114
+
115
+ function columnWidths(cells: readonly Cell[]): ColumnWidths {
116
+ const label = cells.reduce((width, cell) => Math.max(width, cell.label.length), 0) + LABEL_GUTTER
117
+ const count = cells.reduce((width, cell) => Math.max(width, String(cell.count).length), 0) + COUNT_GUTTER
118
+ return { label, count }
119
+ }
120
+
121
+ function renderCell(cell: Cell, widths: ColumnWidths): string {
122
+ const label = cell.label.padEnd(widths.label)
123
+ const count = String(cell.count).padStart(widths.count)
124
+ return cell.bar ? label + count + BAR_GAP + cell.bar : label + count
125
+ }
126
+
127
+ export function summaryLines(stats: Stats): SummaryLine[] {
128
+ const lines: SummaryLine[] = []
129
+ if (stats.ready !== undefined) {
130
+ lines.push({ label: "ready", count: stats.ready })
131
+ }
132
+ if (stats.blocked !== undefined) {
133
+ lines.push({ label: "blocked", count: stats.blocked })
134
+ }
135
+ return lines
136
+ }
@@ -0,0 +1,49 @@
1
+ export interface RefresherOptions<Value> {
2
+ readonly load: (signal: AbortSignal) => Promise<Value>
3
+ readonly onValue: (value: Value) => void
4
+ readonly onError: () => void
5
+ }
6
+
7
+ export interface Refresher {
8
+ refresh(): Promise<void>
9
+ dispose(): void
10
+ }
11
+
12
+ export function createRefresher<Value>(options: RefresherOptions<Value>): Refresher {
13
+ let current: AbortController | null = null
14
+ let disposed = false
15
+
16
+ const settle = (controller: AbortController) => {
17
+ if (current === controller) {
18
+ current = null
19
+ }
20
+ }
21
+
22
+ return {
23
+ async refresh() {
24
+ if (disposed) {
25
+ return
26
+ }
27
+ current?.abort()
28
+ const controller = new AbortController()
29
+ current = controller
30
+ try {
31
+ const value = await options.load(controller.signal)
32
+ if (!controller.signal.aborted) {
33
+ options.onValue(value)
34
+ }
35
+ } catch {
36
+ if (!controller.signal.aborted) {
37
+ options.onError()
38
+ }
39
+ } finally {
40
+ settle(controller)
41
+ }
42
+ },
43
+ dispose() {
44
+ disposed = true
45
+ current?.abort()
46
+ current = null
47
+ },
48
+ }
49
+ }
@@ -0,0 +1,65 @@
1
+ export interface DisposableBag {
2
+ add(dispose: () => void): void
3
+ dispose(): void
4
+ }
5
+
6
+ export function createDisposables(): DisposableBag {
7
+ const pending: Array<() => void> = []
8
+ let disposed = false
9
+
10
+ return {
11
+ add(dispose) {
12
+ if (disposed) {
13
+ dispose()
14
+ return
15
+ }
16
+ pending.push(dispose)
17
+ },
18
+ dispose() {
19
+ if (disposed) {
20
+ return
21
+ }
22
+ disposed = true
23
+ while (pending.length > 0) {
24
+ pending.pop()?.()
25
+ }
26
+ },
27
+ }
28
+ }
29
+
30
+ export interface SlotHost<Claim> {
31
+ readonly ui: {
32
+ readonly slot: (claim: Claim) => () => void
33
+ }
34
+ }
35
+
36
+ export function registerSidebar<Claim>(host: SlotHost<Claim>, claim: Claim): () => void {
37
+ const unregister = host.ui.slot(claim)
38
+ return () => unregister()
39
+ }
40
+
41
+ export const PANEL_NAME = "beans-stats"
42
+ export const PANEL_COMMAND_ID = "beans.stats.panel"
43
+ export const PANEL_BIND = "<leader>d"
44
+
45
+ export interface PanelCommand {
46
+ readonly id: string
47
+ readonly title: string
48
+ readonly description: string
49
+ readonly group: string
50
+ readonly bind: string
51
+ readonly run: () => void
52
+ }
53
+
54
+ export function panelCommand(open: (name: string) => unknown): PanelCommand {
55
+ return {
56
+ id: PANEL_COMMAND_ID,
57
+ title: "Beans breakdown",
58
+ description: "Show the full beans breakdown panel",
59
+ group: "Beans",
60
+ bind: PANEL_BIND,
61
+ run: () => {
62
+ open(PANEL_NAME)
63
+ },
64
+ }
65
+ }
@@ -0,0 +1,135 @@
1
+ import { For, Index, Show, createSignal, type Accessor } from "solid-js"
2
+ import { Plugin, usePlugin } from "@opencode/plugin/tui"
3
+ import {
4
+ layoutBreakdown,
5
+ layoutSection,
6
+ type SectionLayout,
7
+ type Stats,
8
+ } from "./layout.ts"
9
+ import { PANEL_NAME, createDisposables, panelCommand, registerSidebar } from "./setup.ts"
10
+ import { loadStats } from "./beans.ts"
11
+ import { createRefresher } from "./refresh.ts"
12
+ import { CACHE_KEY, createCache, type CacheState } from "./cache.ts"
13
+
14
+ function BeansLines(props: {
15
+ readonly stats: Accessor<Stats | null>
16
+ readonly error: Accessor<string | null>
17
+ readonly layout: (stats: Stats) => SectionLayout
18
+ }) {
19
+ const context = usePlugin()
20
+ return (
21
+ <Show
22
+ when={props.stats()}
23
+ fallback={<text fg={context.theme.text.muted}>{props.error() ?? "loading"}</text>}
24
+ >
25
+ {(stats: Accessor<Stats>) => (
26
+ <>
27
+ <text fg={context.theme.text.base}>{props.layout(stats()).total}</text>
28
+ <Index each={props.layout(stats()).body}>
29
+ {(line) => <text fg={context.theme.text.muted}>{line()}</text>}
30
+ </Index>
31
+ </>
32
+ )}
33
+ </Show>
34
+ )
35
+ }
36
+
37
+ function BeansStats(props: {
38
+ readonly stats: Accessor<Stats | null>
39
+ readonly error: Accessor<string | null>
40
+ }) {
41
+ const context = usePlugin()
42
+ return (
43
+ <box flexDirection="column" gap={0}>
44
+ <text fg={context.theme.text.muted}>BEANS</text>
45
+ <BeansLines stats={props.stats} error={props.error} layout={layoutSection} />
46
+ </box>
47
+ )
48
+ }
49
+
50
+ function BeansBreakdown(props: {
51
+ readonly stats: Accessor<Stats | null>
52
+ readonly error: Accessor<string | null>
53
+ }) {
54
+ const context = usePlugin()
55
+ return (
56
+ <box flexDirection="column" gap={0}>
57
+ <text fg={context.theme.text.muted}>BEANS</text>
58
+ <BeansLines stats={props.stats} error={props.error} layout={layoutBreakdown} />
59
+ </box>
60
+ )
61
+ }
62
+
63
+ export default Plugin.define({
64
+ id: "beans.stats",
65
+ setup(context) {
66
+ const [stats, setStats] = createSignal<Stats | null>(null)
67
+ const [error, setError] = createSignal<string | null>(null)
68
+ const bag = createDisposables()
69
+ const dir = () => (context.location ?? context.data.location.default()).directory
70
+
71
+ const [cacheState, updateCache] = context.storage.store<CacheState>(CACHE_KEY, { initial: {} })
72
+ const cache = createCache(
73
+ {
74
+ read: (directory) => cacheState[directory],
75
+ write: (directory, value) => {
76
+ void updateCache((draft) => {
77
+ draft[directory] = value
78
+ })
79
+ },
80
+ },
81
+ dir(),
82
+ )
83
+
84
+ const cached = cache.seed()
85
+ if (cached !== null) {
86
+ setStats(cached)
87
+ }
88
+
89
+ const refresher = createRefresher<Stats>({
90
+ load: (signal) => loadStats(dir(), { signal }),
91
+ onValue: (value) => {
92
+ setStats(value)
93
+ setError(null)
94
+ cache.save(value)
95
+ },
96
+ onError: () => {
97
+ setStats(null)
98
+ setError("unavailable")
99
+ },
100
+ })
101
+
102
+ void refresher.refresh()
103
+
104
+ bag.add(context.data.on("session.execution.succeeded", () => void refresher.refresh()))
105
+
106
+ const timer = setInterval(() => void refresher.refresh(), 30_000)
107
+ bag.add(() => clearInterval(timer))
108
+
109
+ bag.add(refresher.dispose)
110
+
111
+ bag.add(
112
+ registerSidebar(context, {
113
+ append: "sidebar.content",
114
+ render: () => <BeansStats stats={stats} error={error} />,
115
+ }),
116
+ )
117
+
118
+ bag.add(
119
+ registerSidebar(context, {
120
+ append: "session.panel",
121
+ render: (input) => (
122
+ <Show when={input.name === PANEL_NAME}>
123
+ <BeansBreakdown stats={stats} error={error} />
124
+ </Show>
125
+ ),
126
+ }),
127
+ )
128
+
129
+ context.keymap.layer(() => ({
130
+ commands: [panelCommand((name) => context.ui.panel.open(name))],
131
+ }))
132
+
133
+ return () => bag.dispose()
134
+ },
135
+ })
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pim Snel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,91 @@
1
- # Temporary Holding Version
1
+ # opencode-beans
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ OpenCode v2 plugin that shows [Beans](https://github.com/hmans/beans) issue
4
+ statistics in the sidebar of the OpenCode TUI.
5
+
6
+ ```
7
+ BEANS
8
+ 42 total
9
+
10
+ todo 18 ████
11
+ in-progress 6 █▌
12
+ completed 2 ▌
13
+
14
+ ready 11
15
+ blocked 3
16
+ ```
17
+
18
+ The section refreshes after every assistant turn and on a fallback timer. When
19
+ `.beans/` is missing or `beans` fails it shows a muted `unavailable` line
20
+ instead of crashing.
21
+
22
+ The last counts are cached per project, so a project that has been read before
23
+ paints its counts immediately on startup. The full breakdown, including the
24
+ per-type split, opens in a session panel from a command bound to `<leader>d`.
25
+
26
+ ## Prerequisites
27
+
28
+ `beans`, `openspec` and `jj` must be on `PATH`. Everything else comes from the
29
+ nix flake.
30
+
31
+ ## Install
32
+
33
+ The plugin is discovered automatically from a project's
34
+ `.opencode/plugins/<name>/` directory. To use it in a project:
35
+
36
+ 1. Make sure that project has been initialized with `beans init`, so `.beans/`
37
+ and `.beans.yml` exist at its root.
38
+ 2. Copy the plugin directory into the project:
39
+
40
+ ```bash
41
+ mkdir -p .opencode/plugins
42
+ cp -r /path/to/opencode-beans/.opencode/plugins/beans-stats .opencode/plugins/
43
+ ```
44
+
45
+ 3. Start OpenCode in that project. Confirm the plugin loaded:
46
+
47
+ ```bash
48
+ opencode plugin list
49
+ ```
50
+
51
+ The assembled plugin is also available from `nix build`, see [Build](#build).
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ nix develop # working environment: Node 24, python3, jujutsu, nixfmt
57
+ npm install # dependencies for the tests and the type check
58
+ ```
59
+
60
+ ## Testing
61
+
62
+ ```bash
63
+ npm test # unit and integration tests
64
+ npm run test:e2e # end to end tests against the real OpenCode terminal
65
+ npm run typecheck # TypeScript
66
+ nix flake check # unit and integration tests in a pure sandbox
67
+ ```
68
+
69
+ The unit tests are pure logic. The integration tests run the real `beans` CLI
70
+ against a temporary fixture project and skip when `beans` is not available. The
71
+ end to end tests boot OpenCode against a fixture project in a pseudo terminal
72
+ and assert the rendered sidebar; they skip when their prerequisites are absent.
73
+ See `e2e/README.md` for the harness.
74
+
75
+ ## Build
76
+
77
+ ```bash
78
+ nix build
79
+ ```
80
+
81
+ The plugin files are then at `result/plugins/beans-stats/` (`index.ts`,
82
+ `tui.tsx`, `beans.ts`, `cache.ts`, `layout.ts`, `refresh.ts`, `setup.ts`).
83
+
84
+ ## How work is organised
85
+
86
+ - Milestones and epics: beans. Milestone titles start with `01`, `02`, ...
87
+ - Epic internals: OpenSpec. One change per epic.
88
+ - Version control: jj, committed as Pim Snel.
89
+
90
+ See `AGENTS.md` for the full operating manual and `BRIEFING.md` for the product
91
+ goal.
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "opencode-beans",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Beans stats sidebar plugin for OpenCode v2",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/mipmip/opencode-beans.git"
10
+ },
11
+ "keywords": [
12
+ "opencode",
13
+ "opencode-plugin",
14
+ "beans"
15
+ ],
16
+ "exports": {
17
+ ".": "./.opencode/plugins/beans-stats/index.ts",
18
+ "./tui": "./.opencode/plugins/beans-stats/tui.tsx"
19
+ },
20
+ "files": [
21
+ ".opencode/plugins/beans-stats"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "test": "node --test \"tests/**/*.test.ts\"",
28
+ "test:e2e": "node --test \"e2e/**/*.test.ts\"",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "dependencies": {
32
+ "@opencode/plugin": "^2.0.31"
33
+ },
34
+ "peerDependencies": {
35
+ "@opentui/core": ">=0.6.6",
36
+ "@opentui/solid": ">=0.6.6",
37
+ "solid-js": ">=1.9.0"
38
+ },
39
+ "devDependencies": {
40
+ "@opentui/core": ">=0.6.6",
41
+ "@opentui/solid": ">=0.6.6",
42
+ "@types/node": "^24",
43
+ "solid-js": "^1.9.0",
44
+ "typescript": "^5"
45
+ }
46
+ }