diff --git a/CLAUDE.md b/CLAUDE.md index 2f241da..fa05f2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -223,3 +223,305 @@ function start(app: App): void { console.log(`Starting ${app.config.name}`) } ``` + +## Guide to Writing Tools + +This guide explains how to write a Toes tool. + +A tool is just an app that is displayed in a tab and an iframe on each app's page. When the iframe is loaded, it's passed ?app=. + +### Minimal Tool Structure + +A tool needs three files at minimum: + +**.npmrc** +``` +registry=https://npm.nose.space +``` + +**package.json** +```json +{ + "name": "my-tool", + "private": true, + "module": "index.tsx", + "type": "module", + "scripts": { + "toes": "bun run --watch index.tsx" + }, + "toes": { + "tool": true, // can also be set to a string to control the tab's label + "icon": "🔧" + }, + "dependencies": { + "@because/forge": "*", + "@because/hype": "*", + "@because/toes": "*" + }, + "devDependencies": { + "@types/bun": "latest" + } +} +``` + +**index.tsx** +```tsx +import { Hype } from '@because/hype' +import { define, stylesToCSS } from '@because/forge' +import { baseStyles, ToolScript, theme } from '@because/toes/tools' +import type { Child } from 'hono/jsx' + +const APPS_DIR = process.env.APPS_DIR! + +const app = new Hype({ prettyHTML: false }) + +// Define styled components using Forge +const Container = define('Container', { + fontFamily: theme('fonts-sans'), + padding: '20px', + paddingTop: 0, + maxWidth: '800px', + margin: '0 auto', + color: theme('colors-text'), +}) + +// Layout wrapper (every tool needs this pattern) +function Layout({ title, children }: { title: string; children: Child }) { + return ( + + + + + {title} + + + + + + {children} + + + + ) +} + +// Serve styles (required) +app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, { + 'Content-Type': 'text/css; charset=utf-8', +})) + +// Main route +app.get('/', async c => { + const appName = c.req.query('app') + + if (!appName) { + return c.html( + +

No app selected

+
+ ) + } + + return c.html( + +

Tool for {appName}

+
+ ) +}) + +export default app.defaults +``` + +### Required Imports + +Every tool imports from three packages: + +```tsx +// HTTP helpers +import { Hype } from '@because/hype' + +// Structured CSS +import { define, stylesToCSS } from '@because/forge' + +// Tool helpers +import { baseStyles, ToolScript, theme } from '@because/toes/tools' +``` + +### Environment Variables + +Tools have access to these environment variables: + +| Variable | Description | +|----------|-------------| +| `APPS_DIR` | Path to the apps directory (e.g., `/home/toes/apps`) | +| `TOES_URL` | Base URL of the Toes server (e.g., `http://192.168.1.100:3000`) | +| `PORT` | Port assigned to this tool | + +### Query Parameters + +Tools receive `?app=` when an app is selected. Add custom params as needed: + +```tsx +const appName = c.req.query('app') // Always present when app selected +const version = c.req.query('version') // Custom param (code tool) +const file = c.req.query('file') // Custom param (code tool) +``` + +### Styling with Forge + +Use `define()` to create styled components and `theme()` for design tokens: + +```tsx +const Button = define('Button', { + base: 'button', // HTML element (optional, defaults to 'div') + padding: '8px 16px', + backgroundColor: theme('colors-primary'), + color: 'white', + border: 'none', + borderRadius: theme('radius-md'), + cursor: 'pointer', + states: { + ':hover': { opacity: 0.9 }, + ':disabled': { opacity: 0.5, cursor: 'not-allowed' }, + }, +}) + +const List = define('List', { + listStyle: 'none', + padding: 0, + border: `1px solid ${theme('colors-border')}`, + borderRadius: theme('radius-md'), + selectors: { + '& > li:last-child': { borderBottom: 'none' }, + }, +}) +``` + +Common theme values: +- `colors-text`, `colors-textMuted`, `colors-bg`, `colors-bgHover`, `colors-bgElement`, `colors-bgSubtle` +- `colors-border`, `colors-link`, `colors-primary`, `colors-error` +- `colors-statusRunning`, `colors-statusStopped` +- `fonts-sans`, `fonts-mono` +- `radius-md` + +### ToolScript + +Always include `` in your layout's body. It handles: +1. Theme detection (light/dark mode based on system preference) +2. Iframe height communication with parent dashboard + +### Accessing App Files + +When accessing files in an app, always use the `current` symlink: + +```tsx +import { join } from 'path' +import { readdir } from 'fs/promises' + +const appPath = join(APPS_DIR, appName, 'current') // ✓ Correct +// NOT: join(APPS_DIR, appName) // ✗ Wrong +``` + +### Linking Between Tools + +Use `TOES_URL` to create links to other tools: + +```tsx +const TOES_URL = process.env.TOES_URL! + +// Link from versions tool to code tool + + View Code + +``` + +### Tool Complexity Levels + +**Simple (single file)** - `versions` tool +- Everything in `index.tsx` +- Good for straightforward display tools + +**Medium (re-export)** - `code` tool +- `index.tsx` re-exports: `export { default } from './src/server'` +- Actual implementation in `src/server/index.tsx` +- Good when you want cleaner organization + +**Complex (multiple modules)** - `cron` tool +- Main server in `index.tsx` +- Business logic in `lib/` directory +- Good for tools with significant logic (discovery, scheduling, state management) + +### Common Patterns + +**Error states:** +```tsx +const ErrorBox = define('ErrorBox', { + color: theme('colors-error'), + padding: '20px', + backgroundColor: theme('colors-bgElement'), + borderRadius: theme('radius-md'), +}) + +if (!appName) { + return c.html( + + Please specify an app with ?app=<name> + + ) +} +``` + +**Empty states:** +```tsx +const EmptyState = define('EmptyState', { + padding: '40px 20px', + textAlign: 'center', + color: theme('colors-textMuted'), +}) + +{items.length === 0 ? ( + No items found +) : ( + ... +)} +``` + +**Form handling:** +```tsx +app.get('/new', async c => { + return c.html(
...
) +}) + +app.post('/new', async c => { + const body = await c.req.parseBody() + const name = body.name as string + // Process form... + return c.redirect('/') +}) +``` + +**File watching (for live updates):** +```tsx +import { watch } from 'fs' + +let debounceTimer: Timer | null = null + +watch(APPS_DIR, { recursive: true }, (_event, filename) => { + if (!filename?.includes('/target/')) return + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(handleChange, 100) +}) +``` + +### Checklist for New Tools + +1. [ ] `.npmrc` contains `registry=https://npm.nose.space` +2. [ ] `package.json` has `toes.tool: true` and `toes.icon` +3. [ ] `scripts.toes` uses `bun run --watch index.tsx` +4. [ ] Dependencies include `@because/forge`, `@because/hype`, `@because/toes` +5. [ ] Import `baseStyles`, `ToolScript`, `theme` from `@because/toes/tools` +6. [ ] Layout body includes `` +7. [ ] Styles served at `/styles.css` with `baseStyles + stylesToCSS()` +8. [ ] Main route handles missing `?app` parameter gracefully +9. [ ] Uses `APPS_DIR//current` for file paths +10. [ ] Exports `app.defaults` as default export