A Claude Code mod.

A starting point for a mod for Claude Code: a plugin of function hooks in TypeScript that hot-reloads in the running session. It does one tiny thing, puts “Hello from fastDev” in the status line, and brings everything a real mod needs around it, so the next mod starts from working structure instead of a blank folder.
This repository is a fastDev project skeleton (claude-code-mod). fastDev lists it from a registry, downloads it when first used and creates named, ready-to-run projects from it. Versions are the vX.Y.Z tags of this repository and never change once published.
A new mod for Claude Code: something that shows or changes things in a session (status line, panes, toasts, hooks on turns and tool calls, slash commands) and may need a settings page. The example behaviour is meant to be replaced; the structure is meant to be kept.
| Part | Choice |
|---|---|
| Hooks | hooks/register.ts (thin wiring) + pure, tested code in hooks/lib/ (settings, messages, paths) |
| Command | /<name>: status, on, off, reload, ui |
| Settings | One JSON file in ~/.claude/mod-data/<name>/, shared by the hooks and the page; main switch, language (English/Russian), greeting |
| Settings page | Vue 3, Vite 8; English and Russian, light and dark. Styles are a choice at creation: Tailwind CSS 4 (default) or plain CSS with variables; the components use the same class names with both |
| Settings server | Plain Node 24+, no dependencies, local only (Host/Origin checks, custom header on changes) |
| Tests | Vitest 5: pure modules, the HTTP handler on a real port, the hooks against a fake engine, translations |
| Quality | ESLint 10, Prettier, strict tsc and vue-tsc, and claude plugin validate in npm run check |
npm run types (setup does it) writes Claude Code's declarations for the version you run. Load the project with claude --plugin-dir <folder>, or list it in CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json; the generated README.md and AGENTS.md explain both.
scripts/lockfiles.mjs: regenerates the lockfiles in files/ (one per styles choice) from files/package.json.tmpl; run it after changing a dependency.template.toml: the manifest (ports, the css choice, [[set]] edits that give the plugin its name, setup, commands, translations).files/: the files of a new project (*.tmpl files are rendered with the project name and ports).CHANGELOG.md: what changed in every version.hooks/register.ts 142 lines1// The mod's entry point: Claude Code loads this module and calls `register`.
2// This skeleton puts one line of text into Claude Code (the status line) and
3// has one slash command, `/<plugin name>`. The text and the language come
4// from the settings file the settings UI edits (docs: AGENTS.md, "Data").
5//
6// Keep this file thin: wire events to the pure, unit-tested code in ./lib.
7// A hooks module runs in an environment of its own, with no Node and no DOM;
8// everything outside it goes through `$` (the engine interface).
9
10import type { EngineInterface, Register } from 'claude-code';
11
12import { DEFAULT_CONFIG, resolveConfig, type ModConfig } from './lib/config.ts';
13import { message } from './lib/messages.ts';
14import { configFile, dataDir, nodeCandidates } from './lib/paths.ts';
15
16/**
17 * What the module remembers between hooks. A reload of the module starts it
18 * over (`session.start` fires again and rebinds); what is on disk stays.
19 */
20const S: { home: string; data: string; cfg: ModConfig; cfgMtime: number } = {
21 home: '',
22 data: '',
23 cfg: DEFAULT_CONFIG,
24 cfgMtime: -1,
25};
26
27/** The slash command is the plugin's name; rename it here if it is too long to type. */
28const commandName = ($: EngineInterface): string => $.plugin.name;
29
30const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error));
31
32/** Finds the mod's data folder. */
33async function bind($: EngineInterface): Promise<void> {
34 S.home = (await $.env.get('HOME')) ?? '';
35 S.data = dataDir({ name: $.plugin.name, home: S.home });
36}
37
38/** Reads the settings file again when it changed (or `force`); writes the defaults on first run. */
39async function loadConfig($: EngineInterface, force: boolean): Promise<void> {
40 const path = configFile(S.data);
41 try {
42 const stat = await $.fs.stat(path);
43 if (!force && stat.mtimeMs === S.cfgMtime) return;
44 S.cfg = resolveConfig(JSON.parse(String(await $.fs.read(path))));
45 S.cfgMtime = stat.mtimeMs;
46 } catch {
47 // Missing or unreadable: the defaults, written out so the settings UI has a file to edit.
48 S.cfg = DEFAULT_CONFIG;
49 try {
50 await saveConfig($);
51 } catch (error) {
52 S.cfgMtime = -1;
53 $.ui.log(`${$.plugin.name}: could not write the settings (${errorText(error)})`);
54 }
55 }
56}
57
58async function saveConfig($: EngineInterface): Promise<void> {
59 const path = configFile(S.data);
60 await $.fs.write(path, `${JSON.stringify(S.cfg, null, 2)}\n`);
61 S.cfgMtime = (await $.fs.stat(path)).mtimeMs;
62}
63
64/** The whole feature: the greeting in Claude Code's status line while the mod is on. */
65function showGreeting($: EngineInterface): void {
66 $.ui.status(S.cfg.enabled ? S.cfg.greeting : undefined);
67}
68
69async function startUi($: EngineInterface): Promise<string> {
70 const server = `${$.plugin.root}/ui/server.ts`;
71 const language = S.cfg.language;
72 if (!(await $.fs.exists(server))) return message(language, 'uiMissing', { path: server });
73 for (const node of nodeCandidates(S.home)) {
74 try {
75 const run = await $.process.run([node, server, '--detach', '--data', S.data], { timeoutMs: 15_000 });
76 if (run.exitCode === 0) return run.stdout.trim();
77 } catch {
78 // not found or did not start: try the next place
79 }
80 }
81 return message(language, 'uiFailed', { path: server });
82}
83
84async function runCommand($: EngineInterface, args: string): Promise<string> {
85 const name = $.plugin.name;
86 const language = S.cfg.language;
87 const arg = args.trim().toLowerCase();
88 if (arg === '' || arg === 'status') {
89 return S.cfg.enabled
90 ? message(language, 'statusOn', { greeting: S.cfg.greeting, name, language })
91 : message(language, 'statusOff', { name });
92 }
93 if (arg === 'on' || arg === 'off') {
94 await loadConfig($, true);
95 S.cfg = { ...S.cfg, enabled: arg === 'on' };
96 await saveConfig($);
97 showGreeting($);
98 return message(S.cfg.language, arg === 'on' ? 'enabled' : 'disabled', { name });
99 }
100 if (arg === 'reload') {
101 await loadConfig($, true);
102 showGreeting($);
103 return message(S.cfg.language, 'reloaded');
104 }
105 if (arg === 'ui') return startUi($);
106 return message(language, 'usage', { name });
107}
108
109export const register: Register = (on) => {
110 on('session.start', async ($, e, next) => {
111 const result = await next(e);
112 try {
113 await bind($);
114 await loadConfig($, true);
115 await $.command.register({
116 name: commandName($),
117 description: message(S.cfg.language, 'commandDescription'),
118 argumentHint: message(S.cfg.language, 'commandHint'),
119 });
120 showGreeting($);
121 } catch (error) {
122 $.ui.log(`${$.plugin.name}: start failed (${errorText(error)})`);
123 }
124 return result;
125 });
126
127 on('command.run', async ($, e, next) => {
128 if (e.command !== commandName($)) return next(e);
129 if (S.data === '') await bind($);
130 await loadConfig($, false);
131 return { text: await runCommand($, e.args) };
132 });
133
134 // A change made in the settings UI shows up at the next turn.
135 on('turn.start', async ($, e, next) => {
136 if (S.data === '') await bind($);
137 await loadConfig($, false);
138 showGreeting($);
139 return next(e);
140 });
141};
142hooks/lib/config.ts 86 lines1// The mod's settings: one JSON file shared by the hooks (read) and the settings
2// UI (read and written). Pure code: it runs in the Claude Code engine, in Node
3// and in the browser, so it uses no Node, DOM or engine API.
4
5export const LANGUAGES = ['en', 'ru'] as const;
6export type Language = (typeof LANGUAGES)[number];
7
8export const DEFAULT_GREETING = 'Hello from fastDev';
9export const GREETING_MAX = 120;
10
11export type ModConfig = {
12 /** Format version of the file, for future migrations. */
13 version: 1;
14 /** Main switch: when off, the mod shows nothing in Claude Code. */
15 enabled: boolean;
16 /** Language of the settings UI and of the mod's own messages. */
17 language: Language;
18 /** The text the mod puts in Claude Code (status line and the command's answer). */
19 greeting: string;
20};
21
22export const DEFAULT_CONFIG: ModConfig = {
23 version: 1,
24 enabled: true,
25 language: 'en',
26 greeting: DEFAULT_GREETING,
27};
28
29/** What the UI may change: every setting but the format version. */
30export type ConfigPatch = Partial<Omit<ModConfig, 'version'>>;
31
32export type PatchIssue = {
33 field: string;
34 reason: 'type' | 'empty' | 'too-long' | 'unsupported' | 'unknown-field';
35};
36
37const isRecord = (value: unknown): value is Record<string, unknown> =>
38 typeof value === 'object' && value !== null && !Array.isArray(value);
39
40export const isLanguage = (value: unknown): value is Language =>
41 typeof value === 'string' && (LANGUAGES as readonly string[]).includes(value);
42
43// eslint-disable-next-line no-control-regex -- control characters are what this removes
44const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]+/g;
45
46/** One line, single spaces, no control characters. */
47export function singleLine(text: string): string {
48 return text.replace(CONTROL_CHARACTERS, ' ').replace(/\s+/g, ' ').trim();
49}
50
51/** Reads a settings file's content; whatever is missing or invalid falls back to the default. */
52export function resolveConfig(raw: unknown): ModConfig {
53 if (!isRecord(raw)) return DEFAULT_CONFIG;
54 const greeting = typeof raw.greeting === 'string' ? singleLine(raw.greeting) : '';
55 return {
56 version: 1,
57 enabled: typeof raw.enabled === 'boolean' ? raw.enabled : DEFAULT_CONFIG.enabled,
58 language: isLanguage(raw.language) ? raw.language : DEFAULT_CONFIG.language,
59 greeting: greeting !== '' ? greeting.slice(0, GREETING_MAX) : DEFAULT_CONFIG.greeting,
60 };
61}
62
63/** Checks the body of a settings change strictly: unknown or invalid fields are reported, not dropped. */
64export function parsePatch(input: Record<string, unknown>): { patch: ConfigPatch; issues: PatchIssue[] } {
65 const patch: ConfigPatch = {};
66 const issues: PatchIssue[] = [];
67 for (const [field, value] of Object.entries(input)) {
68 if (field === 'enabled') {
69 if (typeof value === 'boolean') patch.enabled = value;
70 else issues.push({ field, reason: 'type' });
71 } else if (field === 'language') {
72 if (typeof value !== 'string') issues.push({ field, reason: 'type' });
73 else if (isLanguage(value)) patch.language = value;
74 else issues.push({ field, reason: 'unsupported' });
75 } else if (field === 'greeting') {
76 if (typeof value !== 'string') issues.push({ field, reason: 'type' });
77 else if (singleLine(value) === '') issues.push({ field, reason: 'empty' });
78 else if (singleLine(value).length > GREETING_MAX) issues.push({ field, reason: 'too-long' });
79 else patch.greeting = singleLine(value);
80 } else {
81 issues.push({ field, reason: 'unknown-field' });
82 }
83 }
84 return { patch, issues };
85}
86hooks/lib/messages.ts 44 lines1// The mod's own messages (what its command answers), in the language of the
2// settings. English is the source text; every key needs a Russian text, and
3// tests/messages.test.ts keeps both tables in step. `{name}` placeholders are
4// filled by `message`. Pure code.
5
6import type { Language } from './config.ts';
7
8const EN = {
9 commandDescription: 'Show the mod greeting; on, off, ui, reload',
10 commandHint: '[status|on|off|ui|reload]',
11 statusOn: '{greeting}\n{name} is on · language: {language} · settings: /{name} ui',
12 statusOff: '{name} is off. Turn it on with /{name} on or in the settings: /{name} ui',
13 enabled: '{name} is on.',
14 disabled: '{name} is off.',
15 reloaded: 'Settings reloaded.',
16 usage: 'Usage: /{name} [status|on|off|ui|reload]',
17 uiMissing: 'The settings server was not found at {path}.',
18 uiFailed: 'Could not start the settings UI. Start it yourself: node "{path}"',
19} as const;
20
21export type MessageKey = keyof typeof EN;
22
23const RU: Record<MessageKey, string> = {
24 commandDescription: 'Показать приветствие мода; on, off, ui, reload',
25 commandHint: '[status|on|off|ui|reload]',
26 statusOn: '{greeting}\n{name} включён · язык: {language} · настройки: /{name} ui',
27 statusOff: '{name} выключен. Включите: /{name} on или в настройках: /{name} ui',
28 enabled: '{name} включён.',
29 disabled: '{name} выключен.',
30 reloaded: 'Настройки перечитаны.',
31 usage: 'Использование: /{name} [status|on|off|ui|reload]',
32 uiMissing: 'Сервер настроек не найден: {path}.',
33 uiFailed: 'Не удалось запустить интерфейс настроек. Запустите вручную: node "{path}"',
34};
35
36export const MESSAGES: Record<Language, Record<MessageKey, string>> = { en: EN, ru: RU };
37
38export function message(language: Language, key: MessageKey, params: Record<string, string | number> = {}): string {
39 return MESSAGES[language][key].replace(/\{(\w+)\}/g, (whole, name: string) => {
40 const value = params[name];
41 return value === undefined ? whole : String(value);
42 });
43}
44hooks/lib/paths.ts 25 lines1// Where the mod keeps its data. The hooks (inside Claude Code) and the settings
2// UI server (Node) must agree, so both ask this module. Pure string code: it
3// runs where there is no Node `path`.
4
5/** Under the home folder, in a folder of its own: a mod never mixes with Claude Code's files. */
6const DATA_ROOT = '.claude/mod-data';
7
8/** The data folder of the mod `name`: `~/.claude/mod-data/<name>`. */
9export function dataDir(options: { name: string; home: string }): string {
10 return `${options.home}/${DATA_ROOT}/${options.name}`;
11}
12
13export const configFile = (dir: string): string => `${dir}/config.json`;
14
15/** Where to look for Node when Claude Code (started from an app) has no shell PATH. The UI server needs Node 24+. */
16export function nodeCandidates(home: string): string[] {
17 return [
18 'node',
19 '/opt/homebrew/bin/node',
20 '/usr/local/bin/node',
21 `${home}/.local/bin/node`,
22 `${home}/.volta/bin/node`,
23 ];
24}
25