SLOPSHOPPER

plugin-kit

Shared widgets for Claude Code mods: other plugins hand $.kit their state and draw what it returns.

new
v0.1.1MITupdated 2026-10-06aeriondyseti/plugin-kit/plugin
A shopper browsing a rack in a slop shop
README

plugin-kit

A Claude Code plugin that gives every mod a $.kit: hand it widgets, draw what it returns. Plugins that use it look and behave the same, and the kit owns the small interactions (a long list folds behind +3 more).

A mod that must stand alone can instead copy the widget code in with npx @aeriondyseti/plugin-kit add-kit --vendor; see the package README.

Installing

plugin-kit is listed in the aeriondyseti-plugins marketplace:

claude plugin marketplace add aeriondyseti/aeriondyseti-plugins
claude plugin install plugin-kit@aeriondyseti-plugins

Using it from your mod

  1. Depend on it. In your .claude-plugin/plugin.json:
   { "name": "my-mod", "dependencies": [{ "name": "plugin-kit", "marketplace": "aeriondyseti-plugins" }] }

A bare "plugin-kit" would be looked up in your plugin's marketplace, so name ours. (A plugin listed in aeriondyseti-plugins itself can write just "plugin-kit".) For installing your plugin to install the kit too, your marketplace's marketplace.json must allow it:

   { "name": "your-marketplace", "allowCrossMarketplaceDependenciesOn": ["aeriondyseti-plugins"], ... }

Without that line, your users install plugin-kit themselves first (the allowlist doesn't apply to a dependency that is already installed); otherwise your plugin installs but doesn't load, and claude plugin list says Dependency "plugin-kit@aeriondyseti-plugins" is not installed.

Claude Code lays the kit's contract into your .claude-plugin/types/plugin-kit/, so $.kit is typed.

Steps 1 and 2 are one command, run in your plugin's folder:

   npx @aeriondyseti/plugin-kit add-kit
  1. Get hydrate. A mod can't import code from another plugin, and functions can't cross between plugins, so $.kit.render returns a plain JSON description. add-kit copies hydrate.ts (one self-contained file) beside your hooks module; run it again after upgrading to refresh the copy. It builds the elements and gives every Button its onPress.
  1. Draw.
   import { hydrate } from './hydrate.ts'

   on('ui.render', { component: 'Pane', requestId: 'stats' }, async ($, e, next) => {
     const tree = await $.kit.render({
       id: `my-mod:${e.requestId}`,
       widgets: {
         Health: { type: 'meter', value: 88, max: 100, color: 'red', group: 'Body' },
         Suspicion: { type: 'clock', value: 2, of: 6, note: 'The clerk heard something.' },
         Clues: { type: 'list', value: ['a torn ticket', 'wet boots'] },
       },
     })
     const { Box } = $.ui.resolve(e)
     return <Box>{hydrate(tree, h)}</Box>
   })

The drawing redraws by itself when the kit's view state changes (a list folded or unfolded). Give id something unique to the drawing: it keys that state and prefixes the kit's Button keys.

$.kit

MethodGives
render({ id, widgets, barWidth?, listLimit?, glyphs? })A UI description of the widgets, for hydrate. Invalid widgets draw as text.
line({ widgets })One line of plain text, joined with · : for a status line.
parse({ name, widget }){ ok, widget } or { ok: false, error }, the error naming the widget and the fix.
catalog(){ table, schema }: the types as a markdown table for a prompt or skill, and a JSON Schema for one widget as a tool input.

The widget types are documented in types/index.d.ts.

Your own buttons

Buttons you add to the tree get their handler by key: hydrate(tree, h, { save: () => ... }). Keys starting kit: are the kit's.

Settings

The person using Claude Code sets plugin-kit's look once, for every plugin that draws through it, in /config (or claude plugin configure plugin-kit):

SettingDefault
glyphsunicodeascii draws bars as ###---, clocks as **.., bullets as -
barWidth10cells in a meter's bar
listLimit5items a list shows before folding

A plugin that passes barWidth, listLimit or glyphs to render overrides them for its own drawing; most shouldn't.

Restyling the kit

Every $.kit method is also an event, so a theme plugin can rewrite what every caller asks for, e.g. on('kit.render', ($, e, next) => next({ ...e, barWidth: 20 })).

Developing

The widget code lives in ../src/widgets and is copied into hooks/kit/ (a mod imports only its own files). After changing it:

npm run plugin:sync    # refresh hooks/kit/
npm run plugin:check   # claude plugin validate + claude plugin test

npm test fails if hooks/kit/ is stale, and npm run typecheck fails if types/index.d.ts drifts from the widget types.

Source 9 files
hooks/register.ts 65 lines
1// plugin-kit: adds `$.kit` to every mod's `$`, so plugins that list
2// "plugin-kit" under `dependencies` draw widgets the same way.
3//
4// Everything crossing `$.kit` is plain JSON (functions cannot cross between
5// plugins), so `render` returns a UI description, not elements; the caller
6// turns it into elements with `hydrate`. The kit still owns its own buttons:
7// every Button gets an `onPress`, a press raises `ui.press`, and the hook
8// below answers the presses on keys the kit made.
9
10import { atom, update } from 'claude-code';
11import type { Register } from 'claude-code';
12import type { Kit, KitJson } from '../types';
13import { widgetJsonSchema, widgetTable } from './kit/catalog.ts';
14import { describeWidgets, parseWidgetKey } from './kit/describe.ts';
15import { renderWidgetsLine } from './kit/line.ts';
16import { loadWidgets, parseWidget } from './kit/parse.ts';
17
18const expanded = atom({ plugin: 'plugin-kit', key: 'expanded' } as const, {});
19
20export const register: Register = (on, options) => {
21    // The user's settings (userConfig), one look for every plugin using the
22    // kit. A caller's own arguments still win for its drawing.
23    const defaults = {
24        glyphs: options.glyphs === 'ascii' ? 'ascii' : 'unicode',
25        barWidth: typeof options.barWidth === 'number' ? options.barWidth : 10,
26        listLimit: typeof options.listLimit === 'number' ? options.listLimit : 5,
27    } as const;
28
29    on('engine.create', async ($, e, next) => {
30        // At engine.create `$` is empty; the methods reach the engine through
31        // what `next` built, always spelled `built.<noun>.<method>(...)`.
32        const built = await next(e);
33        const kit: Kit = {
34            render: async ({ id, widgets, barWidth, listLimit, glyphs }) => {
35                const state = await built.state.get({ plugin: 'plugin-kit', key: 'expanded' } as const);
36                return describeWidgets(loadWidgets(widgets).widgets, {
37                    id,
38                    expanded: state.value?.[id] ?? [],
39                    barWidth: barWidth ?? defaults.barWidth,
40                    listLimit: listLimit ?? defaults.listLimit,
41                    glyphs: glyphs ?? defaults.glyphs,
42                });
43            },
44            line: async ({ widgets }) => renderWidgetsLine(loadWidgets(widgets).widgets),
45            parse: async ({ name, widget }) => parseWidget(name, widget),
46            catalog: async () => ({
47                table: widgetTable(),
48                schema: widgetJsonSchema() as { [key: string]: KitJson },
49            }),
50        };
51        return { ...built, kit };
52    });
53
54    // Fold and unfold lists. A press on any other key goes on untouched.
55    on('ui.press', async ($, e, next) => {
56        const hit = parseWidgetKey(e.element);
57        if (!hit) return next(e);
58        await update($, expanded, (all) => {
59            const open = (all[hit.id] ?? []).filter((name) => name !== hit.name);
60            return { ...all, [hit.id]: hit.action === 'more' ? [...open, hit.name] : open };
61        });
62        return { element: e.element };
63    });
64};
65
hooks/kit/catalog.ts 80 lines
1// Generated from src/widgets/catalog.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * The widget catalog, written for a model to read: which type fits which
5 * job. Both the markdown table (for a skill or prompt) and the JSON Schema
6 * (for a tool's input) are generated from it, so they cannot drift apart.
7 */
8
9import { COLORS } from './vocab.ts';
10import { MAX_CLOCK_SEGMENTS, WIDGET_TYPE_NAMES, type WidgetType } from './types.ts';
11
12export interface WidgetTypeInfo {
13    /** The type's own fields, as a model should write them. */
14    fields: string;
15    /** When to pick it, with examples. */
16    use: string;
17}
18
19export const WIDGET_CATALOG: Record<WidgetType, WidgetTypeInfo> = {
20    text: {
21        fields: 'value: a few words',
22        use: 'A state in words that changes: the weather, a disguise, the current branch',
23    },
24    counter: {
25        fields: 'value: a number',
26        use: 'A number with no ceiling: days to a deadline, coins owed, retries',
27    },
28    meter: {
29        fields: 'value, max: numbers',
30        use: 'A number out of a known maximum, drawn as a bar: health, fuel, context used',
31    },
32    clock: {
33        fields: 'value, of: whole numbers',
34        use: `Something that happens when it fills, drawn as segments (4, 6 or 8; at most ${MAX_CLOCK_SEGMENTS}): suspicion, a ritual, a countdown`,
35    },
36    list: {
37        fields: 'value: short items',
38        use: 'Things gathered or learned, one per line: clues, allies, open todos',
39    },
40    tags: {
41        fields: 'value: one or two words each',
42        use: 'Conditions that come and go, on one line: wounded, hunted, blocked',
43    },
44};
45
46/** The catalog as a markdown table: `| type | fields | when |`. */
47export function widgetTable(): string {
48    const rows = WIDGET_TYPE_NAMES.map((t) => `| \`${t}\` | ${WIDGET_CATALOG[t].fields} | ${WIDGET_CATALOG[t].use} |`);
49    return ['| type | fields | when |', '|---|---|---|', ...rows].join('\n');
50}
51
52/**
53 * JSON Schema for one widget, as a tool's input property. Flat rather than
54 * one branch per type: a model reads the descriptions, and `parseWidget`
55 * enforces the per-type rules with errors that name the fix.
56 */
57export function widgetJsonSchema(): Record<string, unknown> {
58    return {
59        type: 'object',
60        properties: {
61            type: {
62                type: 'string',
63                enum: [...WIDGET_TYPE_NAMES],
64                description: WIDGET_TYPE_NAMES.map((t) => `${t}: ${WIDGET_CATALOG[t].use}`).join('. '),
65            },
66            value: {
67                description: 'text: a few words. counter, meter, clock: a number. list, tags: an array of short strings.',
68                anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'array', items: { type: 'string' } }],
69            },
70            max: { type: 'number', description: 'meter only: the value the bar is out of' },
71            of: { type: 'integer', minimum: 1, maximum: MAX_CLOCK_SEGMENTS, description: 'clock only: how many segments' },
72            note: { type: 'string', description: 'Optional: one to three short sentences shown dim under the row' },
73            color: { type: 'string', description: `Optional: a hex like "#c0392b" or one of ${COLORS.join(', ')}` },
74            group: { type: 'string', description: 'Optional: a heading shared by neighbouring widgets' },
75        },
76        required: ['type', 'value'],
77        additionalProperties: false,
78    };
79}
80
hooks/kit/describe.ts 135 lines
1// Generated from src/widgets/describe.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * Widgets as a UI description: plain JSON shaped like the element trees
5 * Claude Code's mods draw (`{ type: 'Text', props, children }`).
6 *
7 * Plain data, not elements, because it crosses between plugins: the
8 * plugin-kit mod returns it from `$.kit.render`, and functions cannot cross.
9 * The drawing plugin turns it into elements with `hydrate`, which is also
10 * where every Button gets its `onPress`.
11 */
12
13import { ICONS } from './icons.ts';
14import type { Widget, Widgets } from './types.ts';
15
16export type UiProp = string | number | boolean;
17export type UiNode = string | UiElement;
18export interface UiElement {
19    type: 'Box' | 'Text' | 'Button';
20    props?: Record<string, UiProp>;
21    children?: UiNode[];
22}
23
24/** What a Button in a description does when pressed. */
25export type WidgetAction = 'more' | 'less';
26
27/** Which characters draw bars, segments, bullets and separators. */
28export type WidgetGlyphs = 'unicode' | 'ascii';
29
30/** The characters each glyph set draws with; shared by every renderer. */
31export const GLYPHS: Record<WidgetGlyphs, { full: string; empty: string; on: string; off: string; bullet: string; dot: string }> = {
32    unicode: { full: '▰', empty: '▱', on: '◆', off: '◇', bullet: ICONS.bullet, dot: ICONS.dot },
33    ascii: { full: '#', empty: '-', on: '*', off: '.', bullet: '-', dot: '|' },
34};
35
36export interface DescribeOptions {
37    /**
38     * Names this drawing: it prefixes every Button key, so presses (and any
39     * view state keyed by it) never collide with another drawing's.
40     */
41    id: string;
42    /** Cells in a meter's bar. Default 10. */
43    barWidth?: number;
44    /** Items a list shows before folding the rest behind a button. Default 5. */
45    listLimit?: number;
46    /** Names of the lists drawn unfolded. */
47    expanded?: readonly string[];
48    /** Default `unicode`; `ascii` for surfaces or fonts without the shapes. */
49    glyphs?: WidgetGlyphs;
50}
51
52const KEY_PREFIX = 'kit:';
53
54/** The Button key for `action` on widget `name` in drawing `id`. */
55export function widgetKey(id: string, name: string, action: WidgetAction): string {
56    return `${KEY_PREFIX}${encodeURIComponent(id)}:${encodeURIComponent(name)}:${action}`;
57}
58
59/** The inverse of `widgetKey`; undefined for any key it did not make. */
60export function parseWidgetKey(key: string): { id: string; name: string; action: WidgetAction } | undefined {
61    if (!key.startsWith(KEY_PREFIX)) return undefined;
62    const [id, name, action, ...rest] = key.slice(KEY_PREFIX.length).split(':');
63    if (id === undefined || name === undefined || rest.length > 0) return undefined;
64    if (action !== 'more' && action !== 'less') return undefined;
65    return { id: decodeURIComponent(id), name: decodeURIComponent(name), action };
66}
67
68export function describeWidgets(widgets: Widgets, opts: DescribeOptions): UiElement {
69    const children: UiNode[] = [];
70    let group: string | undefined;
71    for (const [name, widget] of Object.entries(widgets)) {
72        if (widget.group && widget.group !== group) {
73            children.push(text(widget.group, { dimColor: true }));
74        }
75        group = widget.group;
76        children.push(...describeWidget(name, widget, opts));
77    }
78    return box(children, { key: `${KEY_PREFIX}${encodeURIComponent(opts.id)}`, flexDirection: 'column' });
79}
80
81function describeWidget(name: string, widget: Widget, opts: DescribeOptions): UiNode[] {
82    const paint: Record<string, UiProp> = widget.color ? { color: widget.color } : {};
83    const label = text(name, { bold: true, ...paint });
84    const note = widget.note ? [text(widget.note, { dimColor: true, wrap: 'wrap' })] : [];
85    const row = (...rest: UiNode[]) => box([label, ...rest], { flexDirection: 'row', gap: 1 });
86    const g = GLYPHS[opts.glyphs ?? 'unicode'];
87
88    switch (widget.type) {
89        case 'text':
90        case 'counter':
91            return [row(text(String(widget.value))), ...note];
92        case 'meter': {
93            const cells = opts.barWidth ?? 10;
94            const filled = Math.round((widget.value / widget.max) * cells);
95            const bar = g.full.repeat(filled) + g.empty.repeat(cells - filled);
96            return [row(text(bar, paint), text(`${widget.value}/${widget.max}`)), ...note];
97        }
98        case 'clock': {
99            const segments = g.on.repeat(widget.value) + g.off.repeat(widget.of - widget.value);
100            return [row(text(segments, paint)), ...note];
101        }
102        case 'tags':
103            return [row(text(widget.value.join(` ${g.dot} `) || '(none)', paint)), ...note];
104        case 'list':
105            return [label, ...describeItems(name, widget.value, paint, opts), ...note];
106    }
107}
108
109function describeItems(name: string, items: string[], paint: Record<string, UiProp>, opts: DescribeOptions): UiNode[] {
110    const limit = opts.listLimit ?? 5;
111    const isOpen = opts.expanded?.includes(name) ?? false;
112    const shown = isOpen ? items : items.slice(0, limit);
113    const bullet = GLYPHS[opts.glyphs ?? 'unicode'].bullet;
114    const lines: UiNode[] = shown.map((item) => text(`  ${bullet} ${item}`, paint));
115    if (items.length === 0) lines.push(text('  (none)', { dimColor: true }));
116    if (items.length > limit) {
117        const action = isOpen ? 'less' : 'more';
118        const label = isOpen ? 'less' : `+${items.length - limit} more`;
119        lines.push(box([button(widgetKey(opts.id, name, action), label)], { paddingLeft: 2 }));
120    }
121    return lines;
122}
123
124function box(children: UiNode[], props: Record<string, UiProp>): UiElement {
125    return { type: 'Box', props, children };
126}
127
128function text(value: string, props: Record<string, UiProp> = {}): UiElement {
129    return { type: 'Text', props, children: [value] };
130}
131
132function button(key: string, label: string): UiElement {
133    return { type: 'Button', props: { key, label } };
134}
135
hooks/kit/line.ts 33 lines
1// Generated from src/widgets/line.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * The short form of a widget: one line of plain text, as a status line or a
5 * prompt shows it. `Health 88/100`, `Suspicion 2/6`, `Powers: wheel, parry`.
6 */
7
8import { ICONS } from './icons.ts';
9import type { Widget, Widgets } from './types.ts';
10
11export function renderWidgetLine(name: string, widget: Widget): string {
12    switch (widget.type) {
13        case 'text':
14        case 'counter':
15            return `${name} ${widget.value}`;
16        case 'meter':
17            return `${name} ${widget.value}/${widget.max}`;
18        case 'clock':
19            return `${name} ${widget.value}/${widget.of}`;
20        case 'list':
21            return `${name}: ${widget.value.join(', ') || '(none)'}`;
22        case 'tags':
23            return `${name}: ${widget.value.join(` ${ICONS.dot} `) || '(none)'}`;
24    }
25}
26
27/** Every widget's line, joined with ` · `. */
28export function renderWidgetsLine(widgets: Widgets): string {
29    return Object.entries(widgets)
30        .map(([name, widget]) => renderWidgetLine(name, widget))
31        .join(` ${ICONS.dot} `);
32}
33
hooks/kit/parse.ts 150 lines
1// Generated from src/widgets/parse.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * Validation for widgets as a model or a file writes them.
5 *
6 * A bad widget is an expected outcome here, not an exception: the caller is
7 * often a model that reads the error and retries, so `parseWidget` returns a
8 * result and every error names the widget and the fix.
9 */
10
11import { COLORS } from './vocab.ts';
12import {
13    MAX_CLOCK_SEGMENTS,
14    WIDGET_TYPE_NAMES,
15    type Widget,
16    type WidgetColor,
17    type WidgetCommon,
18    type Widgets,
19} from './types.ts';
20
21export type WidgetParseResult = { ok: true; widget: Widget } | { ok: false; error: string };
22
23export interface WidgetLoad {
24    widgets: Widgets;
25    warnings: string[];
26}
27
28const HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
29
30export function parseWidget(name: string, raw: unknown): WidgetParseResult {
31    const fail = (message: string): WidgetParseResult => ({ ok: false, error: `${name}: ${message}` });
32    if (!isRecord(raw)) return fail('a widget is an object with a type and a value');
33
34    const type = WIDGET_TYPE_NAMES.find((t) => t === raw.type);
35    if (!type) {
36        const given = raw.type === undefined ? 'needs a type' : `type "${String(raw.type)}" is unknown`;
37        return fail(`${given}: one of ${WIDGET_TYPE_NAMES.join(', ')}`);
38    }
39
40    const common: WidgetCommon = {};
41    for (const key of ['note', 'group'] as const) {
42        const v = raw[key];
43        if (v === undefined || v === null) continue;
44        if (typeof v !== 'string') return fail(`${key} must be text`);
45        if (v.trim()) common[key] = v.trim();
46    }
47    if (raw.color !== undefined && raw.color !== null && raw.color !== '') {
48        const color = parseColor(raw.color);
49        if (!color) return fail(`color must be a hex like "#c0392b" or one of ${COLORS.join(', ')}`);
50        common.color = color;
51    }
52    if (type !== 'meter' && raw.max !== undefined && raw.max !== null) {
53        return fail(`a ${type} takes no max: use a meter for a value out of a maximum`);
54    }
55    if (type !== 'clock' && raw.of !== undefined && raw.of !== null) {
56        return fail(`a ${type} takes no of: use a clock for segments that fill`);
57    }
58
59    const value = raw.value;
60    switch (type) {
61        case 'text':
62            if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
63                return { ok: true, widget: { type, value: String(value).trim(), ...common } };
64            }
65            return fail('text needs value: a few words');
66        case 'counter':
67            if (!isNumber(value)) return fail('counter needs a number value: put words in a text widget');
68            return { ok: true, widget: { type, value, ...common } };
69        case 'meter': {
70            const max = raw.max;
71            if (!isNumber(max)) return fail('meter needs max: the value is drawn as a bar out of it');
72            if (max <= 0) return fail('meter max must be above 0');
73            if (!isNumber(value)) return fail(`meter needs a number value, out of max ${max}`);
74            if (value < 0 || value > max) {
75                return fail(`meter value ${value} is outside 0 to ${max}: change the value or the max`);
76            }
77            return { ok: true, widget: { type, value, max, ...common } };
78        }
79        case 'clock': {
80            const of = raw.of;
81            if (!isNumber(of)) return fail('clock needs of: the number of segments, usually 4, 6 or 8');
82            if (!Number.isInteger(of) || of < 1 || of > MAX_CLOCK_SEGMENTS) {
83                return fail(`clock of must be a whole number from 1 to ${MAX_CLOCK_SEGMENTS}`);
84            }
85            if (!isNumber(value) || !Number.isInteger(value)) {
86                return fail(`clock needs value: how many of its ${of} segments are filled`);
87            }
88            if (value < 0 || value > of) return fail(`clock value ${value} is outside 0 to ${of}`);
89            return { ok: true, widget: { type, value, of, ...common } };
90        }
91        case 'list':
92        case 'tags': {
93            const items = typeof value === 'string' ? [value] : value;
94            if (!Array.isArray(items) || items.some((i) => typeof i === 'object' && i !== null)) {
95                return fail(`${type} needs value: a list of short items`);
96            }
97            const clean = items.map((i) => String(i ?? '').trim()).filter(Boolean);
98            return { ok: true, widget: { type, value: clean, ...common } };
99        }
100    }
101}
102
103/**
104 * Widgets from somewhere that must not fail to open (a file on disk, a
105 * plugin's stored state). An invalid entry becomes a text widget holding its
106 * raw value, with a warning saying what to fix.
107 */
108export function loadWidgets(raw: unknown): WidgetLoad {
109    if (raw === undefined || raw === null) return { widgets: {}, warnings: [] };
110    if (!isRecord(raw)) return { widgets: {}, warnings: ['widgets should map each name to a widget; ignored'] };
111
112    const widgets: Widgets = {};
113    const warnings: string[] = [];
114    for (const [name, entry] of Object.entries(raw)) {
115        const parsed = parseWidget(name, entry);
116        if (parsed.ok) {
117            widgets[name] = parsed.widget;
118            continue;
119        }
120        warnings.push(`${parsed.error} (shown as text until fixed)`);
121        const value = isRecord(entry) ? entry.value : entry;
122        const note = isRecord(entry) && typeof entry.note === 'string' ? entry.note.trim() : '';
123        widgets[name] = { type: 'text', value: stringify(value), ...(note ? { note } : {}) };
124    }
125    return { widgets, warnings };
126}
127
128function parseColor(raw: unknown): WidgetColor | undefined {
129    if (typeof raw !== 'string') return undefined;
130    const color = raw.trim();
131    if (HEX.test(color)) return color as `#${string}`;
132    return COLORS.find((c) => c === color.toLowerCase());
133}
134
135function stringify(value: unknown): string {
136    if (value === undefined || value === null) return '';
137    if (typeof value === 'string') return value;
138    if (typeof value === 'number' || typeof value === 'boolean') return String(value);
139    if (Array.isArray(value)) return value.map(stringify).join(', ');
140    return JSON.stringify(value);
141}
142
143function isNumber(value: unknown): value is number {
144    return typeof value === 'number' && Number.isFinite(value);
145}
146
147function isRecord(value: unknown): value is Record<string, unknown> {
148    return typeof value === 'object' && value !== null && !Array.isArray(value);
149}
150
hooks/kit/vocab.ts 27 lines
1// Generated from src/formatting/vocab.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * Canonical names for colors and text modifiers.
5 *
6 * Exported as `as const` tuples so callers can both type-check against the
7 * union AND iterate the values (e.g. to build a picker). Adding a new color
8 * here forces every code map (FG/BG in tags.ts) to also cover it.
9 */
10
11export const COLORS = [
12    'black',
13    'red',
14    'green',
15    'yellow',
16    'blue',
17    'magenta',
18    'cyan',
19    'white',
20    'gray',
21    'grey',
22] as const;
23export type ColorName = typeof COLORS[number];
24
25export const MODIFIERS = ['bold', 'dim', 'italic', 'underline'] as const;
26export type ModifierName = typeof MODIFIERS[number];
27
hooks/kit/types.ts 43 lines
1// Generated from src/widgets/types.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * Widgets: small, typed pieces of state a plugin (or the model, through a
5 * plugin's tool) hands over to be drawn. A widget owns its data, never its
6 * placement: the host decides where the rows go.
7 *
8 * This folder is pure — no Node, no dependencies — because it is copied
9 * verbatim into the plugin-kit mod, whose environment has neither.
10 */
11
12import type { ColorName } from './vocab.ts';
13
14export const WIDGET_TYPE_NAMES = ['text', 'counter', 'meter', 'clock', 'list', 'tags'] as const;
15export type WidgetType = typeof WIDGET_TYPE_NAMES[number];
16
17/** A named color from the shared vocab, or a hex like `#c0392b` / `#c33`. */
18export type WidgetColor = ColorName | `#${string}`;
19
20/**
21 * Fields every widget may carry. `color` paints the main part of the row
22 * (never the note); `group` draws a dim heading above the first row of a run.
23 */
24export interface WidgetCommon {
25    note?: string;
26    color?: WidgetColor;
27    group?: string;
28}
29
30export interface TextWidget extends WidgetCommon { type: 'text'; value: string }
31export interface CounterWidget extends WidgetCommon { type: 'counter'; value: number }
32export interface MeterWidget extends WidgetCommon { type: 'meter'; value: number; max: number }
33export interface ClockWidget extends WidgetCommon { type: 'clock'; value: number; of: number }
34export interface ListWidget extends WidgetCommon { type: 'list'; value: string[] }
35export interface TagsWidget extends WidgetCommon { type: 'tags'; value: string[] }
36
37export type Widget = TextWidget | CounterWidget | MeterWidget | ClockWidget | ListWidget | TagsWidget;
38
39/** Widgets by name, in draw order (object key order). */
40export type Widgets = Record<string, Widget>;
41
42export const MAX_CLOCK_SEGMENTS = 12;
43
hooks/kit/icons.ts 27 lines
1// Generated from src/formatting/icons.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * Named icons for use in output strings.
5 *
6 * Drop them in with template literals — no parser, no tag grammar:
7 *
8 *   builder.appendLine(`${ICONS.check} build passed`);
9 *   builder.appendLine(`${ICONS.warn} ${count} files skipped`);
10 *
11 * To add an icon: put it in `ICONS` and it's instantly available.
12 * Typos are compile errors (`ICONS.chek` won't typecheck).
13 */
14
15export const ICONS = {
16    check: '✓',
17    cross: '✗',
18    warn: '⚠',
19    info: 'ℹ',
20    arrow: '▸',
21    bullet: '•',
22    dot: '·',
23    star: '★',
24} as const;
25
26export type IconName = keyof typeof ICONS;
27
types/index.d.ts 92 lines
1// The contract of `$.kit`, the noun plugin-kit adds to every mod's `$`.
2//
3// A plugin that lists "plugin-kit" under `dependencies` in its plugin.json
4// gets this file laid into its own .claude-plugin/types/ by the engine, so
5// `$.kit` is typed with nothing copied. Self-contained by the engine's rule:
6// no imports, every exported name led by `Kit`. It restates the widget types
7// of src/widgets; scripts/contract-check.ts fails the typecheck if they drift.
8
9export type KitColorName = 'black' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white' | 'gray' | 'grey';
10export type KitWidgetColor = KitColorName | `#${string}`;
11
12export interface KitWidgetCommon {
13    note?: string;
14    color?: KitWidgetColor;
15    group?: string;
16}
17export interface KitTextWidget extends KitWidgetCommon { type: 'text'; value: string }
18export interface KitCounterWidget extends KitWidgetCommon { type: 'counter'; value: number }
19export interface KitMeterWidget extends KitWidgetCommon { type: 'meter'; value: number; max: number }
20export interface KitClockWidget extends KitWidgetCommon { type: 'clock'; value: number; of: number }
21export interface KitListWidget extends KitWidgetCommon { type: 'list'; value: string[] }
22export interface KitTagsWidget extends KitWidgetCommon { type: 'tags'; value: string[] }
23export type KitWidget = KitTextWidget | KitCounterWidget | KitMeterWidget | KitClockWidget | KitListWidget | KitTagsWidget;
24
25/** Widgets by name, in draw order. */
26export type KitWidgets = { [name: string]: KitWidget };
27
28/**
29 * A UI description: plain JSON shaped like a mod's element tree. Turn it into
30 * elements with `hydrate` (src/widgets/hydrate.ts, copied into your hooks).
31 */
32export type KitUiProp = string | number | boolean;
33export type KitUiNode = string | KitUiElement;
34export interface KitUiElement {
35    type: 'Box' | 'Text' | 'Button';
36    props?: { [name: string]: KitUiProp };
37    children?: KitUiNode[];
38}
39
40/** Which characters draw bars, segments, bullets and separators. */
41export type KitGlyphs = 'unicode' | 'ascii';
42
43export type KitParseResult = { ok: true; widget: KitWidget } | { ok: false; error: string };
44
45export type KitJson = string | number | boolean | null | KitJson[] | { [key: string]: KitJson };
46
47export interface KitRenderArgs {
48    /**
49     * Names this drawing, e.g. `${plugin}:${e.requestId}`. It keys the kit's
50     * view state (which lists are unfolded) and prefixes its Button keys.
51     */
52    id: string;
53    /**
54     * The widgets, in draw order. Unchecked input is fine: an invalid widget
55     * draws as text rather than failing the drawing (use `parse` to refuse).
56     */
57    widgets: { [name: string]: KitJson };
58    /**
59     * The three below override the user's plugin-kit settings for this
60     * drawing; leave them out to draw the way the user chose.
61     */
62    /** Cells in a meter's bar. The user's default, else 10. */
63    barWidth?: number;
64    /** Items a list shows before folding the rest behind a button. The user's default, else 5. */
65    listLimit?: number;
66    /** The user's default, else `unicode`. */
67    glyphs?: KitGlyphs;
68}
69
70export type Kit = {
71    /** The widgets as a UI description. Read inside `ui.render`, it redraws when the kit's view state changes. */
72    render(args: KitRenderArgs): Promise<KitUiElement>;
73    /** The widgets on one line of plain text, joined with ` · `: for a status line. */
74    line(args: { widgets: { [name: string]: KitJson } }): Promise<string>;
75    /** Validates one widget; every error names the widget and the fix. */
76    parse(args: { name: string; widget: KitJson }): Promise<KitParseResult>;
77    /** The catalog for a model: a markdown table, and a JSON Schema for one widget as a tool input. */
78    catalog(): Promise<{ table: string; schema: { [key: string]: KitJson } }>;
79};
80
81declare module 'claude-code' {
82    interface EngineInterface {
83        kit: Kit;
84    }
85    interface PluginState {
86        'plugin-kit': {
87            /** Per drawing id, the names of its lists drawn unfolded. */
88            expanded: { [id: string]: string[] };
89        };
90    }
91}
92