tools guide
This commit is contained in:
parent
303d2dfc72
commit
11caa8fe19
302
CLAUDE.md
302
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=<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=<name></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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user