nose-pluto/CLAUDE.md
Chris Wanstrath 9d85418293 claude info
2025-10-07 10:50:01 -07:00

320 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
description: NOSE Pluto - A Commodore 64-inspired web terminal with Bun-based shell
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: true
---
# NOSE Pluto
NOSE Pluto is a web-based terminal inspired by the Commodore 64, running on a Bun-based shell server. It hosts Val Town-style web apps for your home network, runs custom commands written in TypeScript, and lives on a Raspberry Pi 5.
## Architecture Overview
- **Server**: Bun + Hono serving a C64-inspired terminal over WebSockets
- **Client**: Browser-based terminal UI with virtual 960×540 (16:9) screen that scales
- **Commands**: TypeScript files that run on the server and return output to the client
- **Projects**: Multi-project system with isolated commands, webapps, and state
- **Webapps**: Subdomain-based apps (e.g., `myapp.nose-pluto.local`)
- **Games**: PICO-8-style game engine with `init()`, `update()`, `draw()` API
- **Session**: AsyncLocalStorage-based session management (1 browser tab = 1 session)
## Technology Stack
Use Bun exclusively instead of Node.js:
- `bun <file>` instead of `node <file>` or `ts-node <file>`
- `bun test` instead of `jest` or `vitest`
- `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- `bun run <script>` instead of `npm run <script>`
- Bun automatically loads .env, so don't use dotenv
### Bun APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes (don't use `express`)
- `bun:sqlite` for SQLite (don't use `better-sqlite3`)
- `Bun.redis` for Redis (don't use `ioredis`)
- `Bun.sql` for Postgres (don't use `pg` or `postgres.js`)
- `WebSocket` is built-in (don't use `ws`)
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- `Bun.$\`ls\`` instead of execa
## Key Concepts
### Commands
Commands are TypeScript files in `bin/` directories that export a `default` function:
```ts
// bin/hello.ts
export default async function (name: string): Promise<string> {
return `Hello, ${name}!`
}
```
Commands can return:
- `string` - plain text output
- `{ text: string, script?: string }` - text with optional client-side JS
- `{ html: string, script?: string }` - HTML with optional client-side JS
- `{ script: string }` - client-side JS only
- `{ error: string }` - error message
- `{ game: string }` - launch a game
Commands can also `throw` to display an error.
**Command Locations** (searched in order):
1. `~/nose/<project>/bin/` - project-specific commands
2. `~/nose/root/bin/` - global user commands (NOSE_ROOT_BIN)
3. `./bin/` - built-in system commands (NOSE_BIN)
**Documentation**: Commands use `//` comments at the top of the file for inline help:
```ts
// This command does something cool.
// Usage: mycmd <arg1> <arg2>
export default async function (arg1: string, arg2: string) {
// ...
}
```
Use `/// <reference lib="dom" />` on the first line to mark commands that run in the browser.
### Projects
Projects live in `~/nose/<project-name>/` and have:
- `bin/` - project-specific commands
- `index.ts` or `index.tsx` - webapp entry point (optional)
- `pub/` - static files served at subdomain (optional)
The `root` project is special - it's the default project and its `bin/` contains global commands.
**Project Structure**:
```
~/nose/
root/ # default project
bin/ # global commands
myproject/ # custom project
bin/ # project commands
index.ts # webapp (served at myproject.nose-pluto.local)
pub/ # static files
```
### Webapps
Webapps are served on subdomains (e.g., `myapp.nose-pluto.local`). They can:
- Export a `default` Hono app for dynamic routes
- Export a `default` function that returns a Response, string, or JSX
- Serve static files from `pub/` directory
```ts
// ~/nose/myapp/index.ts
import { Hono } from "hono"
const app = new Hono()
app.get("/", c => c.text("Hello from myapp!"))
export default app
```
Static files in `pub/` are served automatically (e.g., `pub/index.html``myapp.nose-pluto.local/`).
### Games
Games are commands with `export const game = true` and three exports:
```ts
/// <reference lib="dom" />
export const game = true
import type { GameContext, InputState } from "@/shared/game"
export function init() {
// Initialize game state
}
export function update(delta: number, input: InputState) {
// Update game logic (60 FPS)
}
export function draw(game: GameContext) {
// Draw to 960×540 canvas
game.clear()
game.rectfill(0, 0, 100, 100, "red")
game.text("Score: 0", 10, 10, "white")
}
```
**GameContext API**: `clear()`, `text()`, `centerText()`, `rect()`, `rectfill()`, `circ()`, `circfill()`, `line()`, `oval()`, `ovalfill()`, `rrect()`, `rrectfill()`, `trianglefill()`, `polygonfill()`
**InputState**: Tracks keyboard/gamepad with `pressed`, `prevPressed`, `justPressed`, `justReleased` Sets.
### Session Management
Sessions use AsyncLocalStorage for context isolation:
```ts
import { sessionGet, sessionSet } from "@/session"
// Get session data
const project = sessionGet("project")
const cwd = sessionGet("cwd")
// Set session data (auto-syncs to client)
sessionSet("project", "myproject")
sessionSet("cwd", "/some/path")
```
Session keys: `sessionId`, `taskId`, `project`, `cwd`, `ws`
## File Structure
```
src/
server.tsx # Main Hono server
websocket.ts # WebSocket connection management
dispatch.ts # Client→server message router
shell.ts # Command execution (server-side)
commands.ts # Command discovery and loading
session.ts # Session management (AsyncLocalStorage)
project.ts # Project helpers
webapp.ts # Webapp hosting
state.ts # Persistent state storage
config.ts # Environment configuration
html/
layout.tsx # Base HTML layout
terminal.tsx # Terminal UI components
js/
main.ts # Client entry point
websocket.ts # WebSocket client
shell.ts # Command execution (client-side)
scrollback.ts # Terminal scrollback buffer
input.ts # Input handling
completion.ts # Tab completion
history.ts # Command history
editor.ts # File editor
game.ts # Game engine client
browser.ts # Embedded browser
shared/
types.ts # Shared TypeScript types
game.ts # Game engine types and context
utils.ts # Shared utilities
bin/ # Built-in commands
public/ # Static assets (CSS, fonts, images)
```
## Development Workflow
```bash
# Local development with hot reload
bun dev
# Production mode (no DNS)
bun prod-nodns
# Production mode (with DNS)
bun prod
# Type checking
bun check
# Deploy to Raspberry Pi
bun deploy
```
**Environment Variables**:
- `NOSE_DIR` - Directory for projects (default: `~/nose`)
- `NODE_ENV` - Set to `production` for production mode
- `NO_DNS` - Disable DNS features in production
- `BUN_HOT` - Enable hot reload (set by `bun dev`)
## Important Patterns
### Path Aliases
Use `@/` for imports from `src/` when writing commands:
```ts
import { sessionGet } from "@/session"
import { commands } from "@/commands"
import type { Message } from "@/shared/types"
```
### Command Patterns
**Simple text command**:
```ts
export default function () {
return "Hello, world!"
}
```
**HTML output**:
```ts
export default function () {
return { html: "<h1>Hello</h1>" }
}
```
**JSX output** (auto-converted):
```tsx
export default function () {
return <h1>Hello</h1>
}
```
**Client-side script**:
```ts
export default function () {
return {
text: "Running script...",
script: "alert('Hello from browser!')"
}
}
```
### WebSocket Messages
Messages between client and server use typed message objects:
```ts
// Client → Server
{ type: "input", id: "abc123", session: "0", data: "ls" }
{ type: "save-file", id: "/path/to/file", session: "0", data: "content" }
{ type: "session:update", data: { project: "myproject" } }
// Server → Client
{ type: "output", id: "abc123", data: { status: "ok", output: "result" } }
{ type: "commands", data: ["ls", "cd", "help"] }
{ type: "apps", data: ["myapp", "otherapp"] }
{ type: "game:start", id: "abc123", data: "snake" }
{ type: "session:start", data: { NOSE_DIR, project, cwd, mode, hostname } }
```
## Testing
Use `bun test`:
```ts
import { test, expect } from "bun:test"
test("command output", () => {
expect(1).toBe(1)
})
```
## Notes
- Virtual screen size is fixed at 960×540 (16:9) in "cinema" mode and scales to fit the display
- Virtual screen size is fixed at 960x100%vh in "tall" mode and scales to fit the display
- Font: C64 Pro Mono (see `public/vendor/`)
- Commands are hot-reloaded when files change
- WebSocket connections are managed per-session (1 tab = 1 session)
- Session state persists across reconnects
- The `help` command extracts documentation from `//` comments in commands