Skip to content

shri/claude-electron-starter-app

Repository files navigation

Claude Electron Starter

An opinionated starter for building macOS desktop apps with Electron, tuned for productive Claude Code sessions.

Electron + React 19 + TypeScript 6 + Vite 8 + Tailwind CSS v4 + Bun — wired up with strict lint, a no-useEffect policy, a secure typed-IPC preload, and a .claude/ directory full of slash commands and subagents that keep Claude aligned with the codebase conventions.

If you've ever started an Electron app and spent the first two days fighting tooling, this is the scaffold you wish you'd had.


Why another Electron starter?

Most Electron starters ship you the bones and leave the opinions to you. This one ships the opinions, because opinions are what make a codebase pleasant to work in with an AI pair programmer:

  • Claude knows the rules. CLAUDE.md encodes the commands, planning guidelines, and non-negotiable conventions. .claude/rules/ holds path-scoped rules for React and Electron. .claude/commands/ provides /commit, /pr, /review-code, /simplify, /useeffect-janitor, and /comment-janitor — working slash commands adapted from real production setups.
  • The process boundary is real. src/main, src/preload, and src/renderer can never import each other's runtime code. Shared types go in src/shared/. The preload exposes a typed window.api via contextBridge, never raw ipcRenderer.
  • Security defaults are non-negotiable. contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true. The rules file tells Claude to flag (not silently change) anything that would weaken these.
  • Strict everything. TypeScript strict + noUncheckedIndexedAccess + exactOptionalPropertyTypes. ESLint flat config with strictTypeChecked + stylisticTypeChecked. --max-warnings=0. No any, no as X as a shortcut, no eslint-disable without a scoped reason.
  • React 19, the way React 19 wants to be written. Direct useEffect is banned by ESLint. forwardRef is banned. useContext is banned (use the use() hook). A sanctioned useMountEffect wrapper is the one place useEffect is allowed, with narrowly-scoped disables that model how the rest of the codebase should handle exceptions.
  • Bun everywhere. Install, scripts, formatting, type-checking — all via bun. Auto-loaded .env, faster installs, one binary.

Stack

Layer Choice Why
App framework Electron The desktop runtime
Build tooling electron-vite Three-process (main/preload/renderer) Vite with HMR
UI React 19 Compiler-aware, use() hook, ref-as-prop
Language TypeScript 6 (strict) Project references split node vs. web
Styling Tailwind CSS v4 CSS-first via @import "tailwindcss"
Lint ESLint 10 flat config + typescript-eslint Strict type-checked rules, banned imports
Format Prettier with @ianvs/prettier-plugin-sort-imports + prettier-plugin-tailwindcss
Hooks Lefthook Pre-commit format, pre-push check-types + lint
Package manager Bun Install + script runner
Packaging electron-builder .dmg for macOS (signing/notarization TODO)

Quickstart

# 1. Use this as a template
gh repo create my-app --template shri/claude-electron-starter-app --public --clone
cd my-app

# 2. Install
bun install

# 3. Launch the dev window
bun run dev

You should see an Electron window titled "Claude Electron Starter" with a Tailwind-styled hello screen and a button that round-trips through IPC to the main process.

Or clone directly

git clone https://github.com/shri/claude-electron-starter-app.git my-app
cd my-app
rm -rf .git && git init -b main
bun install
bun run dev

Commands

Task Command Notes
Dev bun run dev electron-vite with HMR on main, preload, and renderer
Build bun run build Type-checks then builds all three processes to out/
Build .dmg bun run build:mac Packages to release/ — unsigned by default
Type-check bun run check-types Separate tsc --noEmit passes for node and web tsconfigs
Lint bun run lint ESLint flat config, --max-warnings=0
Lint (fix) bun run lint:fix
Format bun run format Prettier over the whole tree
Format check bun run format:check CI-style, no writes

Always bun, never npm. bunx, never npx.


Repository layout

.
├── .claude/                     # Claude Code configuration
│   ├── settings.json            # Permissions, hooks, statusline
│   ├── statusline.sh            # Model + git + context + tokens statusline
│   ├── hooks/
│   │   ├── format-file.sh       # Prettier on every Edit/Write
│   │   └── protect-files.sh     # Blocks writes to .env, bun.lock, .git, out/
│   ├── rules/
│   │   ├── react.md             # React 19 conventions (path-scoped to **/*.tsx)
│   │   └── electron.md          # Security model + process boundary
│   ├── agents/
│   │   └── code-reviewer.md     # opus subagent invoked by /review-code
│   └── commands/                # Slash commands — see below
├── .vscode/                     # Format on save, ESLint, Tailwind IntelliSense
├── src/
│   ├── main/
│   │   └── index.ts             # Electron main: window + IPC handlers
│   ├── preload/
│   │   └── index.ts             # contextBridge surface (typed Api export)
│   └── renderer/
│       ├── index.html
│       └── src/
│           ├── main.tsx
│           ├── app.tsx
│           ├── env.d.ts         # Ambient Window.api type + vite/client
│           ├── hooks/
│           │   └── use-mount-effect.ts  # sanctioned useEffect wrapper
│           ├── lib/
│           │   └── cn.ts        # conditional classnames helper
│           └── styles/
│               └── globals.css  # Tailwind v4 entry
├── CLAUDE.md                    # Claude Code operational doc
├── electron.vite.config.ts
├── electron-builder.yml
├── eslint.config.js
├── tsconfig.json                # Project references
├── tsconfig.node.json           # main + preload + config files
└── tsconfig.web.json            # renderer

The @ alias resolves to src/renderer/src/* in the renderer — set in both tsconfig.web.json and electron.vite.config.ts.


Working with Claude Code

Drop into the repo, start Claude Code, and the .claude/ directory is picked up automatically. The things you'll use most:

Slash commands

Command What it does
/commit Stages + analyzes the diff and writes a conventional commit message (emoji + type + summary). Asks before guessing on ambiguous changes.
/pr Runs quality gates (check-types, lint), pushes the branch, and opens a GitHub PR with a synthesized title and description. Accepts draft as an argument.
/review-code Delegates to the code-reviewer subagent (runs on Opus). Reports only issues worth fixing, grouped by severity. Works on uncommitted changes, a specific commit, or a file.
/simplify Reduces complexity in the target files using explicit simplification principles (early returns, small functions, no nested ternaries, delete dead code). Re-runs type-check + lint after.
/useeffect-janitor Refactors common useEffect anti-patterns (derived state, reset-on-prop, event-driven logic, chains of effects, data fetching, notify-parent) into their React 19 equivalents. Leaves legitimate effects alone.
/comment-janitor Two-pass: removes noisy comments that restate code, adds helpful ones for non-obvious business logic.

Subagents

  • code-reviewer (opus) — invoked by /review-code. Reviews for correctness, dead code, readability, type safety, performance, and convention violations. Skips praise and nitpicks.

You can chain these at the end of a task: /useeffect-janitor/review-code/comment-janitorbun run check-types && bun run lint/commit/pr.

Hooks

Two .claude/hooks/ scripts run automatically:

  • format-file.sh — on every Edit/Write, runs prettier --write on the touched file so formatting never drifts from the saved state.
  • protect-files.sh — on every Edit/Write, blocks writes to .env*, bun.lock, .git/, out/, dist/, release/, and node_modules/. Edit the PROTECTED_PATTERNS array to add your own.

Statusline

.claude/statusline.sh shows the current model, directory, git branch + diff summary, context window usage (with a colored progress bar), token count, and session duration in the Claude Code status line. No config needed — it's just wired up.

Plan mode by default

.claude/settings.json sets defaultMode: "plan" — Claude will draft a plan and show it for approval before making changes on any non-trivial task. Override per-session if plan mode is too chatty for your flow.


The hard rules (and why they exist)

These are encoded in CLAUDE.md, .claude/rules/react.md, .claude/rules/electron.md, and enforced by ESLint where possible.

React 19

  • useEffect is banned. Direct imports of useEffect from react are a lint error. Alternatives live in .claude/rules/react.md:

    • Mount/cleanup → useMountEffect (the one sanctioned wrapper)
    • Derived state → compute during render
    • Event-driven logic → put it in the handler
    • Reset on prop change → use the key prop
    • Data fetching → use a data library (TanStack Query, SWR, etc.)
    • Notify parent of state change → call the callback in the same handler

    Running /useeffect-janitor refactors common anti-patterns automatically.

  • No forwardRef. React 19 supports ref as a regular prop.

  • No useContext. Use the use() hook — it can be called conditionally.

  • No speculative useMemo/useCallback. The React Compiler handles memoization. Only use them when an external dependency needs a stable identity.

Electron

  • Never disable contextIsolation, sandbox, webSecurity, or enable nodeIntegration.
  • Never expose raw ipcRenderer to the renderer. Use contextBridge.exposeInMainWorld('api', {...}) in src/preload/index.ts with a typed surface.
  • The renderer never imports fs, child_process, path, os. If it needs one, add an IPC method in main and validate the input at the boundary.
  • src/main and src/renderer can't import each other's code. Shared types go in src/shared/ (type-only, no runtime).

Types & Lint (non-negotiables)

  • Avoid type assertions. as X is a code smell — if you're reaching for it, consider whether the types are modeled wrong.
  • Never as any or unknown as X. Hard rule.
  • Don't disable ESLint/TypeScript rules without explicit consent. If you must, scope the disable as tightly as possible and explain why. The codebase has exactly one place that does this: src/renderer/src/hooks/use-mount-effect.ts, which is the sanctioned useEffect wrapper. That's the template for how to write a principled exception.

Code conventions

  • kebab-case file names (enforced by eslint-plugin-check-file)
  • Array<T> not T[] (enforced by @typescript-eslint/array-type)
  • No barrel files (index.ts re-exports)
  • No nested ternaries
  • Top-to-bottom readability: helpers go below their consumers
  • Conditional classnames via cn() from @/lib/cn, never template literals
  • Sorted imports (automatic via @ianvs/prettier-plugin-sort-imports): builtins → third-party → @/* → relative, blank line between groups

IPC: adding a new handler

The pattern is end-to-end typed. To add a readFile(path) method:

  1. Main (src/main/index.ts):

    ipcMain.handle('read-file', async (_event, filePath: string): Promise<string> => {
      // Validate — untrusted input. Guard against path traversal, etc.
      return await readFile(filePath, 'utf8');
    });
  2. Preload (src/preload/index.ts):

    const api = {
      ping: (): Promise<string> => ipcRenderer.invoke('ping'),
      readFile: (path: string): Promise<string> => ipcRenderer.invoke('read-file', path),
    } as const;
  3. Renderer ambient type (src/renderer/src/env.d.ts):

    interface StemMagicApi {
      ping: () => Promise<string>;
      readFile: (path: string) => Promise<string>;
    }
  4. Renderer anywhere:

    const contents = await window.api.readFile('/path/to/file');

For a production app, wrap step 1 with a Zod schema (or similar) to validate the argument before it reaches the filesystem.


Distribution (TODO)

bun run build:mac produces an unsigned .dmg in release/. To ship a distributable build you still need to:

  1. Get an Apple Developer ID Application certificate
  2. Set APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID as environment variables
  3. Add an afterSign hook to electron-builder.yml that calls @electron/notarize
  4. Update appId and productName to match your app

This starter intentionally doesn't wire up signing — every project's signing story is different, and getting it wrong corrupts builds in subtle ways.


Customizing

A few things you'll probably want to change first:

  • App identity: package.json (name, description), electron-builder.yml (appId, productName, category), src/main/index.ts (setAppUserModelId), src/renderer/index.html (title), src/renderer/src/app.tsx (hero text).
  • Window: src/main/index.ts — size, title bar style, background color, min constraints.
  • Protected files: .claude/hooks/protect-files.sh — add paths Claude shouldn't overwrite.
  • Permissions: .claude/settings.json permissions.allow — add any project-specific commands you want pre-authorized.
  • Default mode: .claude/settings.json — change defaultMode from "plan" to "acceptEdits" or "default" if plan mode is too chatty for your flow.

License

MIT. Use it, fork it, rip the .claude/ directory into your own scaffold — no credit required but appreciated.


Credits

The .claude/ setup is adapted from the production Claude Code configuration of the Gobi codebase — particularly the slash commands, the code-reviewer subagent, the React 19 rules, and the format-on-write/protect-files hook pattern. Full credit to that team for figuring out what actually works.

About

Opinionated Claude Code starter for macOS Electron + React 19 + TypeScript + Tailwind v4 apps. Strict lint, no-useEffect policy, IPC-safe preload, Bun everywhere.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors