SLOPSHOPPER

app

A Claude Code mod.

newcommandstatusprocess
v0.1.0UNLICENSEDupdated 2026-10-06roma-vibe/fastdev-claude-code-mod/files
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · app
› fix the failing auth test and add an audit log call ● app: app: could not write the settings (ENOENT: /Users/dev/.claude/mod-data/app/config.json) ● app: app: could not write the settings (ENOENT: /Users/dev/.claude/mod-data/app/config.json) ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /app ⎿ app: Hello from fastDev ⎿ app: app is on · language: en · settings: /app ui ● app: app: could not write the settings (ENOENT: /Users/dev/.claude/mod-data/app/config.json) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ app: Hello from fastDev
README

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.

When to choose it

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.

What is inside

PartChoice
Hookshooks/register.ts (thin wiring) + pure, tested code in hooks/lib/ (settings, messages, paths)
Command/<name>: status, on, off, reload, ui
SettingsOne JSON file in ~/.claude/mod-data/<name>/, shared by the hooks and the page; main switch, language (English/Russian), greeting
Settings pageVue 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 serverPlain Node 24+, no dependencies, local only (Host/Origin checks, custom header on changes)
TestsVitest 5: pure modules, the HTTP handler on a real port, the hooks against a fake engine, translations
QualityESLint 10, Prettier, strict tsc and vue-tsc, and claude plugin validate in npm run check

Using a new mod

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.

Files

  • 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.
Source 4 files
hooks/register.ts 142 lines
1// 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};
142
hooks/lib/config.ts 86 lines
1// 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}
86
hooks/lib/messages.ts 44 lines
1// 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}
44
hooks/lib/paths.ts 25 lines
1// 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