A band above the prompt with the session's time, cost, project, branch and rate limits.

<h1>Koubou (工房)</h1>
Koubou is a Claude Code plugin marketplace. Each plugin does one job, and you can enable or disable each one on its own. Each plugin has its own evals, or its own tests when it is a mod.
| Plugin | Provides | What it does |
|---|---|---|
crew | Sidebar mod | Shows a side panel with a card for each agent at work. |
ember | Background mod | Keeps the prompt cache warm while you are away, and warns when a resumed one went cold. |
git | git:gitignore | Builds and updates .gitignore files from the official github/gitignore templates. |
kanso | Two skills, a hook | Sets one writing style for all text, and turns substantial answers into pages. |
ribbon | Prompt band mod | Shows a band above the prompt with the session's time, cost, project, branch and limits. |
workflow | Five skills | Plans, coordinates, builds test first, reviews and opens pull requests. Needs kanso. |
Add the marketplace, then install the plugins you want:
/plugin marketplace add fabioueno/koubou
/plugin install crew@koubou
/plugin install ember@koubou
/plugin install git@koubou
/plugin install kanso@koubou
/plugin install ribbon@koubou
/plugin install workflow@koubou
Disable a plugin, or enable it again, without uninstalling it:
/plugin disable kanso@koubou
/plugin enable kanso@koubou
Plugins carry no version, so each one updates when a commit changes its folder. Run /plugin marketplace update koubou to fetch new commits.
| Document | Description |
|---|---|
| [CONTRIBUTING.md][contributing] | Conventions, adding a plugin, and running evals. |
[MIT][license]
[contributing]: CONTRIBUTING.md [license]: LICENSE
hooks/register.ts 10 lines1import type { Register } from "claude-code";
2
3import { onAbovePrompt } from "./render/above-prompt";
4import { onSessionStart } from "./session/start";
5
6export const register: Register = (on, options) => {
7 onSessionStart(on, options);
8 onAbovePrompt(on, options);
9};
10hooks/render/above-prompt.tsx 37 lines1import type { On, PluginOptions } from "claude-code";
2import { atom, read } from "claude-code";
3
4import { band } from "../status/band";
5
6// Declared in each file that reads them: the mod validator follows an atom only
7// within its own file.
8const status = atom({ plugin: "ribbon", key: "status" } as const, null);
9
10/**
11 * The band, kept next to the prompt, under whatever the other plugins draw
12 * above the prompt, so each one's part shows whatever order the plugins run in.
13 */
14export function onAbovePrompt(on: On, options: PluginOptions): void {
15 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
16 const current = await read($, status);
17 if (e.surface !== "terminal" || e.props.hasSurvey || current === null) {
18 return next(e);
19 }
20
21 const E = $.ui.resolve(e);
22 const above = await next(e);
23
24 return (
25 <E.Box flexDirection="column">
26 {above}
27 {band(E, {
28 status: current,
29 width: e.props.bodyColumns,
30 showCost: options.showCost !== false,
31 showRateLimits: options.showRateLimits !== false,
32 })}
33 </E.Box>
34 );
35 });
36}
37hooks/session/start.ts 92 lines1import type { EngineInterface, On, PluginOptions } from "claude-code";
2import { atom, update } from "claude-code";
3
4import type { Status } from "../../types";
5import { fablePercent } from "../status/fable";
6import { gaugeOf } from "../status/limits";
7import { basename, homeOf } from "../text/paths";
8
9// Declared in each file that reads them: the mod validator follows an atom only
10// within its own file.
11const status = atom({ plugin: "ribbon", key: "status" } as const, null);
12
13// The status line script refreshed the Fable usage cache as a side effect; with
14// the status line gone, a timer runs it for the same cache.
15interface Usage {
16 cache: string;
17 script: string;
18}
19
20async function usageOf($: EngineInterface, options: PluginOptions): Promise<Usage | undefined> {
21 if (options.showRateLimits === false) {
22 return undefined;
23 }
24
25 const dir = (await $.env.get("CLAUDE_CONFIG_DIR")) ?? `${await homeOf($)}/.claude`;
26
27 return { cache: `${dir}/cache/statusline-usage.json`, script: `${dir}/statusline.sh` };
28}
29
30async function placeOf($: EngineInterface, cwd: string): Promise<Pick<Status, "project" | "branch" | "worktree">> {
31 const git = async (...args: string[]): Promise<string | undefined> => {
32 const ran = await $.process.run(["git", "-C", cwd, ...args]).catch(() => undefined);
33 return ran?.exitCode === 0 ? ran.stdout.trim() : undefined;
34 };
35
36 const [branch, gitDir, commonDir, top] = await Promise.all([
37 git("branch", "--show-current"),
38 git("rev-parse", "--absolute-git-dir"),
39 git("rev-parse", "--path-format=absolute", "--git-common-dir"),
40 git("rev-parse", "--show-toplevel"),
41 ]);
42
43 const isWorktree = gitDir !== undefined && commonDir !== undefined && gitDir !== commonDir && top !== undefined;
44
45 return {
46 project: basename(await $.session.root().catch(() => cwd)),
47 branch: branch === undefined ? undefined : branch || "detached",
48 worktree: isWorktree ? basename(top) : undefined,
49 };
50}
51
52async function refreshStatus($: EngineInterface, usageFiles: Usage | undefined): Promise<void> {
53 const [usage, cwd, now] = await Promise.all([$.session.usage(), $.session.cwd(), $.clock.now()]);
54 const fable = usageFiles && (await $.fs.read(usageFiles.cache).then(fablePercent, () => undefined));
55
56 const next: Status = {
57 now,
58 startedAt: usage.startedAt,
59 costUsd: usage.cost?.usd ?? 0,
60 fiveHour: gaugeOf(usage.rateLimits, "five_hour"),
61 sevenDay: gaugeOf(usage.rateLimits, "seven_day"),
62 fable,
63 ...(await placeOf($, cwd)),
64 };
65
66 await update($, status, () => next);
67}
68
69function startTimers($: EngineInterface, usageFiles: Usage | undefined): void {
70 $.clock.every(15_000, () => {
71 refreshStatus($, usageFiles).catch(() => undefined);
72 });
73
74 if (usageFiles !== undefined) {
75 $.clock.every(180_000, () => {
76 $.process.run(["bash", usageFiles.script], { stdin: "{}" }).catch(() => undefined);
77 });
78 }
79}
80
81export function onSessionStart(on: On, options: PluginOptions): void {
82 on("session.start", async ($, e, next) => {
83 const started = await next(e);
84 const usageFiles = await usageOf($, options);
85
86 await refreshStatus($, usageFiles).catch(() => undefined);
87 startTimers($, usageFiles);
88
89 return started;
90 });
91}
92hooks/status/band.tsx 90 lines1import type { RenderElement } from "claude-code";
2
3import type { Gauge, Status } from "../../types";
4import type { Els } from "../cards/elements";
5import { CARD_BORDER, MUTED, RAMP } from "../palette/colors";
6import { GLYPHS } from "../palette/glyphs";
7import { gradient } from "../palette/gradient";
8import { duration, untilReset } from "../text/units";
9
10// The fixed-colour items, in the order they take the ramp.
11function fixedItems(view: BandView): [string, string][] {
12 const { status } = view;
13 const items: [string, string][] = [[GLYPHS.clock, duration(status.now - status.startedAt)]];
14
15 if (view.showCost) {
16 items.push([GLYPHS.cost, status.costUsd.toFixed(2)]);
17 }
18
19 items.push([GLYPHS.project, status.project]);
20
21 if (status.branch !== undefined) {
22 items.push([GLYPHS.branch, status.branch]);
23 }
24 if (status.worktree !== undefined) {
25 items.push([GLYPHS.worktree, status.worktree]);
26 }
27
28 return items;
29}
30
31function gauge(E: Els, glyph: string, limit: Gauge | undefined, view: BandView, fable?: number): RenderElement | null {
32 const { Box, Text } = E;
33 if (limit === undefined && fable === undefined) {
34 return null;
35 }
36
37 const reset = untilReset(limit?.resetsAt, view.status.now);
38
39 return (
40 <Box gap={1}>
41 {limit !== undefined && (
42 <Text wrap="truncate-end" color={gradient(limit.percent)}>
43 {glyph} {Math.trunc(limit.percent)}%
44 </Text>
45 )}
46
47 {fable !== undefined && (
48 <Text wrap="truncate-end" color={gradient(fable)}>
49 {GLYPHS.fable} {fable}%
50 </Text>
51 )}
52
53 {reset !== undefined && (
54 <Text wrap="truncate-end" color={MUTED}>
55 {reset}
56 </Text>
57 )}
58 </Box>
59 );
60}
61
62export interface BandView {
63 status: Status;
64 width: number;
65 showCost: boolean;
66 showRateLimits: boolean;
67}
68
69/**
70 * One rounded line: fixed items along a pastel ramp first, then the gauges
71 * coloured by their value.
72 */
73export function band(E: Els, view: BandView): RenderElement {
74 const { Box, Text } = E;
75 const { status } = view;
76
77 return (
78 <Box borderStyle="round" borderColor={CARD_BORDER} paddingX={1} columnGap={4} flexWrap="wrap" width={view.width}>
79 {fixedItems(view).map(([glyph, text], index) => (
80 <Text wrap="truncate-end" color={RAMP[Math.min(index, RAMP.length - 1)]}>
81 {glyph} {text}
82 </Text>
83 ))}
84
85 {view.showRateLimits && gauge(E, GLYPHS.hourglass, status.fiveHour, view)}
86 {view.showRateLimits && gauge(E, GLYPHS.calendar, status.sevenDay, view, status.fable)}
87 </Box>
88 );
89}
90hooks/status/fable.ts 42 lines1type Loose = Readonly<Record<string, unknown>>;
2
3function scopedFable(usage: Loose): Loose | undefined {
4 const limits = Array.isArray(usage.limits) ? (usage.limits as Loose[]) : [];
5
6 return limits.find((limit) => {
7 const model = (limit.scope as Loose | undefined)?.model as Loose | undefined;
8
9 return (
10 limit.kind === "weekly_scoped" &&
11 String(model?.display_name ?? "")
12 .toLowerCase()
13 .includes("fable")
14 );
15 });
16}
17
18function pickOf(usage: Loose): { percent: number; reset: unknown } | undefined {
19 const scoped = scopedFable(usage);
20 if (scoped) {
21 return { percent: Number(scoped.percent ?? 0), reset: scoped.resets_at };
22 }
23
24 const flat = (usage.seven_day_overage_included ?? usage.seven_day_fable) as Loose | undefined;
25
26 return flat ? { percent: Number(flat.utilization ?? 0), reset: flat.resets_at } : undefined;
27}
28
29/**
30 * The Fable weekly percent from the usage endpoint's cache: its scoped limit
31 * first, else the flat bucket under either of its names. A zero percent with no
32 * reset is a window the account does not have.
33 */
34export function fablePercent(raw: string): number | undefined {
35 const pick = pickOf(JSON.parse(raw) as Loose);
36 if (!pick || (pick.percent === 0 && (pick.reset === null || pick.reset === undefined))) {
37 return undefined;
38 }
39
40 return Math.trunc(pick.percent);
41}
42hooks/status/limits.ts 9 lines1import type { SessionRateLimit } from "claude-code";
2
3import type { Gauge } from "../../types";
4
5export function gaugeOf(limits: readonly SessionRateLimit[], kind: string): Gauge | undefined {
6 const limit = limits.find((each) => each.kind === kind);
7 return limit ? { percent: limit.percentUsed, resetsAt: limit.resetsAt } : undefined;
8}
9hooks/text/paths.ts 10 lines1import type { EngineInterface } from "claude-code";
2
3export async function homeOf($: EngineInterface): Promise<string> {
4 return (await $.env.get("HOME")) ?? "";
5}
6
7export function basename(path: string): string {
8 return path.split("/").filter(Boolean).at(-1) ?? path;
9}
10hooks/cards/elements.ts 4 lines1import type { Elements } from "claude-code";
2
3export type Els = Elements["terminal"];
4hooks/palette/colors.ts 6 lines1// Hex blended toward the Carbonfox background (#161616): the terminal has no
2// alpha, so a softer colour is a mix with the background.
3export const CARD_BORDER = "#4A4A4A";
4export const MUTED = "#6C7086";
5export const RAMP = ["#F2CDCD", "#F5C2E7", "#CBA6F7", "#B4BEFE", "#89B4FA", "#74C7EC", "#89DCEB"] as const;
6hooks/palette/glyphs.ts 11 lines1export const GLYPHS = {
2 branch: "\u{e725}",
3 calendar: "\u{f073}",
4 clock: "\u{f017}",
5 cost: "\u{f155}",
6 fable: "\u{f02d}",
7 hourglass: "\u{f254}",
8 project: "\u{f07b}",
9 worktree: "\u{f0405}",
10} as const;
11hooks/palette/gradient.ts 19 lines1function hex(red: number, green: number, blue: number): string {
2 return `#${[red, green, blue].map((value) => Math.round(value).toString(16).padStart(2, "0")).join("")}`;
3}
4
5/**
6 * The gauge colour for a percent: green through yellow to red, along the
7 * Catppuccin Mocha anchors the old status line used.
8 */
9export function gradient(percent: number): string {
10 const clamped = Math.max(0, Math.min(100, percent));
11 if (clamped < 50) {
12 return hex(166 + (83 * clamped) / 50, 227 - clamped / 50, 161 + (14 * clamped) / 50);
13 }
14
15 const past = clamped - 50;
16
17 return hex(249 - (6 * past) / 50, 226 - (87 * past) / 50, 175 - (7 * past) / 50);
18}
19hooks/text/units.ts 41 lines1/**
2 * The two largest units that apply: `1h 02m`, `7m 03s`, `12s`.
3 */
4export function duration(milliseconds: number): string {
5 const seconds = Math.max(0, Math.floor(milliseconds / 1000));
6 const hours = Math.floor(seconds / 3600);
7 const minutes = Math.floor((seconds % 3600) / 60);
8
9 if (hours > 0) {
10 return `${hours}h ${String(minutes).padStart(2, "0")}m`;
11 }
12 if (minutes > 0) {
13 return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
14 }
15
16 return `${seconds}s`;
17}
18
19/**
20 * The countdown to an ISO reset time, or nothing once it has passed.
21 */
22export function untilReset(resetsAt: string | undefined, now: number): string | undefined {
23 if (resetsAt === undefined) {
24 return undefined;
25 }
26
27 const seconds = Math.floor((Date.parse(resetsAt) - now) / 1000);
28
29 if (!(seconds > 0)) {
30 return undefined;
31 }
32 if (seconds >= 86_400) {
33 return `${Math.floor(seconds / 86_400)}d ${Math.floor((seconds % 86_400) / 3600)}h`;
34 }
35 if (seconds >= 3600) {
36 return duration(seconds * 1000);
37 }
38
39 return seconds >= 60 ? `${Math.floor(seconds / 60)}m` : "<1m";
40}
41