tools guide

This commit is contained in:
Chris Wanstrath 2026-02-04 08:39:00 -08:00
parent 303d2dfc72
commit 11caa8fe19

302
CLAUDE.md
View File

@ -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=<app-name>.
### 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 (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<ToolScript />
<Container>
{children}
</Container>
</body>
</html>
)
}
// 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(
<Layout title="My Tool">
<p>No app selected</p>
</Layout>
)
}
return c.html(
<Layout title="My Tool">
<h1>Tool for {appName}</h1>
</Layout>
)
})
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=<name>` 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 `<ToolScript />` 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
<a href={`${TOES_URL}/tool/code?app=${appName}&version=${version}`}>
View Code
</a>
```
### 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(
<Layout title="My Tool">
<ErrorBox>Please specify an app with ?app=&lt;name&gt;</ErrorBox>
</Layout>
)
}
```
**Empty states:**
```tsx
const EmptyState = define('EmptyState', {
padding: '40px 20px',
textAlign: 'center',
color: theme('colors-textMuted'),
})
{items.length === 0 ? (
<EmptyState>No items found</EmptyState>
) : (
<List>...</List>
)}
```
**Form handling:**
```tsx
app.get('/new', async c => {
return c.html(<Layout><form method="post" action="/new">...</form></Layout>)
})
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 `<ToolScript />`
7. [ ] Styles served at `/styles.css` with `baseStyles + stylesToCSS()`
8. [ ] Main route handles missing `?app` parameter gracefully
9. [ ] Uses `APPS_DIR/<app>/current` for file paths
10. [ ] Exports `app.defaults` as default export