562 lines
14 KiB
Markdown
562 lines
14 KiB
Markdown
# Toes - Claude Code Guide
|
|
|
|
## What It Is
|
|
|
|
Personal web appliance that auto-discovers and runs multiple web apps on your home network.
|
|
|
|
"Plug it in, turn it on, and forget about the cloud."
|
|
|
|
## How It Works
|
|
|
|
1. Host server scans `/apps` directory for valid apps
|
|
2. Valid app = has `package.json` with `scripts.toes` entry
|
|
3. Each app spawned as child process with unique port (3001+)
|
|
4. Dashboard UI shows all apps with current status, logs, and links
|
|
|
|
## Key Files
|
|
|
|
### Server (`src/server/`)
|
|
- `apps.ts` - **The heart**: app discovery, process management, health checks, auto-restart
|
|
- `api/apps.ts` - REST API for app lifecycle (start/stop/restart, logs, icons, rename)
|
|
- `api/sync.ts` - File sync protocol (manifest, push/pull, watch)
|
|
- `index.tsx` - Entry point (minimal, initializes Hype)
|
|
- `shell.tsx` - HTML shell for web UI
|
|
|
|
### Client (`src/client/`)
|
|
- `components/` - Dashboard, Sidebar, AppDetail, Nav
|
|
- `modals/` - NewApp, RenameApp, DeleteApp dialogs
|
|
- `styles/` - Forge CSS-in-JS (themes, buttons, forms, layout)
|
|
- `state.ts` - Client state management
|
|
- `api.ts` - API client
|
|
|
|
### CLI (`src/cli/`)
|
|
- `commands/manage.ts` - list, start, stop, restart, info, new, rename, delete, open
|
|
- `commands/sync.ts` - push, pull, sync
|
|
- `commands/logs.ts` - log viewing with tail support
|
|
|
|
### Shared (`src/shared/`)
|
|
- Code shared between frontend (browser) and backend (server)
|
|
- `types.ts` - App, AppState, Manifest interfaces
|
|
- IMPORTANT: Cannot use filesystem or Node APIs (runs in browser)
|
|
|
|
### Lib (`src/lib/`)
|
|
- Code shared between CLI and server (server-side only)
|
|
- `templates.ts` - Template generation for new apps
|
|
- Can use filesystem and Node APIs (never runs in browser)
|
|
|
|
### Other
|
|
- `apps/*/package.json` - Must have `"toes": "bun run --watch index.tsx"` script
|
|
- `TODO.txt` - Task list
|
|
|
|
## Tools
|
|
|
|
Tools are special apps that appear as tabs in the dashboard rather than standalone entries in the sidebar. They integrate directly into the Toes UI and can interact with the currently selected app.
|
|
|
|
### Creating a Tool
|
|
|
|
Add `toes.tool` to your app's `package.json`:
|
|
|
|
```json
|
|
{
|
|
"toes": {
|
|
"icon": "🔧",
|
|
"tool": true
|
|
},
|
|
"scripts": {
|
|
"toes": "bun run --watch index.tsx"
|
|
}
|
|
}
|
|
```
|
|
|
|
### How Tools Work
|
|
|
|
- Tools run as regular apps (spawned process with unique port)
|
|
- Displayed as iframes overlaying the tab content area
|
|
- Receive `?app=<name>` query parameter for the currently selected app
|
|
- Iframes are cached per tool+app combination (never recreated once loaded)
|
|
- Tool state persists across tab switches
|
|
- **App paths**: When accessing app files, tools must use `APPS_DIR/<app>/current` (not just `APPS_DIR/<app>`) to resolve through the version symlink
|
|
|
|
### CLI Flags
|
|
|
|
```bash
|
|
toes list # Lists all apps including tools
|
|
toes list --tools # Lists tools only
|
|
toes list --apps # Lists regular apps only (excludes tools)
|
|
```
|
|
|
|
### Tool vs App
|
|
|
|
| Aspect | Regular App | Tool |
|
|
|--------|-------------|------|
|
|
| `toes.tool` | absent/false | true |
|
|
| UI location | Sidebar | Tab bar |
|
|
| Rendering | New browser tab | Iframe in dashboard |
|
|
| Context | Standalone | Knows selected app via query param |
|
|
|
|
## Tech Stack
|
|
|
|
- **Bun** runtime (not Node)
|
|
- **Hype** (custom HTTP framework wrapping Hono) from git+https://git.nose.space/defunkt/hype
|
|
- **Forge** (typed CSS-in-JS) from git+https://git.nose.space/defunkt/forge
|
|
- **Commander** + **kleur** for CLI
|
|
- TypeScript + Hono JSX
|
|
|
|
## Running
|
|
|
|
```bash
|
|
bun run --hot src/server/index.tsx # Dev mode with hot reload
|
|
```
|
|
|
|
## App Structure
|
|
|
|
```tsx
|
|
// apps/example/index.tsx
|
|
import { Hype } from "@because/hype"
|
|
const app = new Hype()
|
|
app.get("/", (c) => c.html(<h1>Content</h1>))
|
|
export default app.defaults
|
|
```
|
|
|
|
## Conventions
|
|
|
|
- Apps get `PORT` env var from host
|
|
- Each app is isolated process with own dependencies
|
|
- No path-based routing - apps run on separate ports
|
|
- `DATA_DIR` env controls where apps are discovered
|
|
- Path aliases: `$` → server, `@` → shared, `%` → lib
|
|
|
|
## Environment Variables
|
|
|
|
Env vars are stored per-app in `TOES_DIR/env/`:
|
|
|
|
```
|
|
${DATA_DIR}/toes/env/
|
|
clock.env # env vars for clock app
|
|
todo.env # env vars for todo app
|
|
```
|
|
|
|
`TOES_DIR` defaults to `${DATA_DIR}/toes`. Apps cannot access this directory directly.
|
|
|
|
## Current State
|
|
|
|
### Infrastructure (Complete)
|
|
- App discovery, spawn, watch, auto-restart with exponential backoff
|
|
- Health checks every 30s (3 failures trigger restart)
|
|
- Port pool (3001-3100), sticky allocation per app
|
|
- SSE streams for real-time app state and log updates
|
|
- File sync protocol with hash-based manifests
|
|
|
|
### CLI
|
|
- Full management: `toes list|start|stop|restart|info|new|rename|delete|open`
|
|
- File sync: `toes push|pull|sync`
|
|
- Logs: `toes logs [-f] <app>`
|
|
|
|
Check `TODO.txt` for planned features
|
|
|
|
## Coding Guidelines
|
|
|
|
TS files should be organized in the following way:
|
|
|
|
- imports
|
|
- re-exports
|
|
- const/lets
|
|
- enums
|
|
- interfaces
|
|
- types
|
|
- classes
|
|
- functions
|
|
- module init (top level function calls)
|
|
|
|
In each section, put the `export`s first, in alphabetical order.
|
|
|
|
Then, after the `export`s (if there were any), put everything else,
|
|
also in alphabetical order.
|
|
|
|
For single-line functions, use `const fn = () => {}` and put them in the
|
|
"functions" section of the file.
|
|
|
|
All other functions use the `function blah(){}` format.
|
|
|
|
Example:
|
|
|
|
```ts
|
|
import { code } from "coders"
|
|
import { something } from "somewhere"
|
|
|
|
export type { SomeType }
|
|
|
|
const RETRY_TIMES = 5
|
|
const WIDTH = 480
|
|
|
|
enum State {
|
|
Stopped,
|
|
Starting,
|
|
Running,
|
|
}
|
|
|
|
interface Config {
|
|
name: string
|
|
port: number
|
|
}
|
|
|
|
type Handler = (req: Request) => Response
|
|
|
|
class App {
|
|
config: Config
|
|
|
|
constructor(config: Config) {
|
|
this.config = config
|
|
}
|
|
}
|
|
|
|
const isApp = (name: string) =>
|
|
apps.has(name)
|
|
|
|
function createApp(name: string): App {
|
|
const app = new App({ name, port: 3000 })
|
|
apps.set(name, app)
|
|
return app
|
|
}
|
|
|
|
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 four files at minimum:
|
|
|
|
**.npmrc**
|
|
```
|
|
registry=https://npm.nose.space
|
|
```
|
|
|
|
**tsconfig.json**
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"lib": ["ESNext"],
|
|
"target": "ESNext",
|
|
"module": "Preserve",
|
|
"moduleDetection": "force",
|
|
"jsx": "react-jsx",
|
|
"jsxImportSource": "hono/jsx",
|
|
"allowJs": true,
|
|
"moduleResolution": "bundler",
|
|
"allowImportingTsExtensions": true,
|
|
"verbatimModuleSyntax": true,
|
|
"noEmit": true,
|
|
"strict": true,
|
|
"skipLibCheck": true,
|
|
"noFallthroughCasesInSwitch": true,
|
|
"noUncheckedIndexedAccess": true,
|
|
"noImplicitOverride": true,
|
|
"noUnusedLocals": false,
|
|
"noUnusedParameters": false,
|
|
"noPropertyAccessFromIndexSignature": false,
|
|
"baseUrl": ".",
|
|
"paths": {
|
|
"$*": ["src/server/*"],
|
|
"#*": ["src/client/*"],
|
|
"@*": ["src/shared/*"]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**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. [ ] `tsconfig.json` matches the exact config shown above (do not improvise)
|
|
3. [ ] `package.json` has `toes.tool: true` and `toes.icon`
|
|
4. [ ] `scripts.toes` uses `bun run --watch index.tsx`
|
|
5. [ ] Dependencies include `@because/forge`, `@because/hype`, `@because/toes`
|
|
6. [ ] Import `baseStyles`, `ToolScript`, `theme` from `@because/toes/tools`
|
|
7. [ ] Layout body includes `<ToolScript />`
|
|
8. [ ] Styles served at `/styles.css` with `baseStyles + stylesToCSS()`
|
|
9. [ ] Main route handles missing `?app` parameter gracefully
|
|
10. [ ] Uses `APPS_DIR/<app>/current` for file paths
|
|
11. [ ] Exports `app.defaults` as default export
|