LintelJS project standards and safety hooks

<img src="https://raw.githubusercontent.com/Faran52/linteljs/main/docs/assets/logo.svg" alt="linteljs" width="96">
<h1 align="center">linteljs</h1>
<a href="https://github.com/Faran52/linteljs/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Faran52/linteljs/ci.yml?branch=main&logo=github&style=for-the-badge" alt="Build Status"></a> <a href="https://github.com/Faran52/linteljs/actions/workflows/ci.yml?query=branch%3Amain"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FFaran52%2Flinteljs%2Fbadges%2Fcoverage.json&style=for-the-badge" alt="Coverage"></a> <a href="https://www.npmjs.com/package/@linteljs/create"><img src="https://img.shields.io/npm/v/@linteljs/create.svg?style=for-the-badge" alt="npm"></a> <a href="https://www.npmjs.com/package/@linteljs/create"><img src="https://img.shields.io/node/v/@linteljs/create?style=for-the-badge" alt="Node"></a>
linteljs ships as three packages: a scaffolder, a shared ESLint flat config, and the custom rules behind it. The scaffolder writes a starter app with its tests, ESLint flat config, TypeScript settings, git hooks, and coding-agent rules and hooks, for React, Next.js, Vue, Nuxt, Svelte, Solid, Angular, Astro, React Native through Expo, Manifest V3 web extensions, and plain TypeScript libraries. Its check runs lint, the banned-pattern check, CSS lint, the typecheck, coverage at 100% when the project has tests, and the build, and passes on the first run.
Node 22.18 or newer.
npm create @linteljs my-app
cd my-app
npm run check
pnpm create @linteljs my-app
cd my-app
pnpm check
yarn create @linteljs my-app
cd my-app
yarn run check
bun create @linteljs my-app
cd my-app
bun run check
For an existing project, npx @linteljs/create --existing applies the standard in place, and sync, run through the project's own manager, updates it. The @linteljs/create README has the details.
| Package | Version | Use it for |
|---|---|---|
@linteljs/create | Start a project, or bring an existing one under the standard. | |
@linteljs/eslint-config | Compose ESLint flat-config layers, by hand or through composeConfig. | |
@linteljs/eslint-plugin | Use the rules on their own. recommended holds the ones the config builds on. |
Copied configuration drifts quietly: a missing setting disables a rule while two config files still look alike. linteljs keeps the shared rules in a published package and the generated files explicit, so an update arrives as a reviewable diff.
pnpm install
pnpm check
Needs Node 26.10.0+ and pnpm 12.6+. docs/CONTRIBUTING.md covers the rest, including the networked end-to-end suite.
hooks/checkBand.tsx 96 lines1// The band above the prompt: what the commit gate would say about the work tree now.
2import {
3 atom,
4 type EngineInterface,
5 type On,
6 read,
7 type Register,
8 update,
9} from 'claude-code';
10
11import type { CheckWord } from '../types/index.d.ts';
12
13const check = atom({
14 plugin: 'linteljs',
15 key: 'check',
16} as const, null);
17
18// Each state's glyph and its colour.
19export const MARKS: Record<CheckWord, [string, string]> = {
20 passed: ['✓', 'green'],
21 stale: ['◐', 'yellow'],
22 failed: ['✗', 'red'],
23 running: ['◐', 'yellow'],
24 none: ['○', 'gray'],
25};
26
27const isWord = (text: string): text is CheckWord => {
28 return Object.hasOwn(MARKS, text);
29};
30
31// Outside a git project with a `check` script the script prints nothing, and the band stays away.
32const printed = async ($: EngineInterface, cwd: string): Promise<string> => {
33 try {
34 const status = await $.process.run(['node', `${$.plugin.root}/hooks/checkStatusHook.ts`], { cwd });
35
36 return status.stdout.trim();
37 }
38 catch {
39 return '';
40 }
41};
42
43const refresh = async ($: EngineInterface): Promise<void> => {
44 const cwd = await $.session.root();
45 const text = await printed($, cwd);
46
47 await update($, check, () => {
48 return isWord(text) ? text : null;
49 });
50};
51
52export const registerCheckRefresh = (on: On): void => {
53 on('session.start', async ($, e, next) => {
54 const started = await next(e);
55 await refresh($);
56
57 return started;
58 });
59
60 on('turn.complete', async ($, e, next) => {
61 const completed = await next(e);
62
63 if (e.agentId === undefined) {
64 await refresh($);
65 }
66
67 return completed;
68 });
69};
70
71export const register: Register = (on) => {
72 registerCheckRefresh(on);
73
74 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
75 const word = await read($, check);
76
77 if (e.props.hasSurvey || word === null) {
78 return next(e);
79 }
80
81 const { Box, Text } = $.ui.resolve(e);
82 const [glyph, color] = MARKS[word];
83 const below = await next(e);
84
85 return (
86 <Box flexDirection="column">
87 <Text>
88 <Text color={color}>{glyph}</Text>
89 {` check ${word}`}
90 </Text>
91 {below}
92 </Box>
93 );
94 });
95};
96types/index.d.ts 10 lines1// The state word `hooks/checkStatusHook.ts` prints, which the check band draws.
2export type CheckWord = 'failed' | 'none' | 'passed' | 'running' | 'stale';
3
4// Inline, since `claude plugin validate` reads the state's shape from this declaration.
5declare module 'claude-code' {
6 interface PluginState {
7 linteljs: { check: CheckWord | null };
8 }
9}
10