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

8.9 KiB
Raw Permalink Blame History

description globs alwaysApply
NOSE Pluto - A Commodore 64-inspired web terminal with Bun-based shell *.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json 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:

// 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:

// 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
// ~/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.htmlmyapp.nose-pluto.local/).

Games

Games are commands with export const game = true and three exports:

/// <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:

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

# 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:

import { sessionGet } from "@/session"
import { commands } from "@/commands"
import type { Message } from "@/shared/types"

Command Patterns

Simple text command:

export default function () {
  return "Hello, world!"
}

HTML output:

export default function () {
  return { html: "<h1>Hello</h1>" }
}

JSX output (auto-converted):

export default function () {
  return <h1>Hello</h1>
}

Client-side script:

export default function () {
  return {
    text: "Running script...",
    script: "alert('Hello from browser!')"
  }
}

WebSocket Messages

Messages between client and server use typed message objects:

// 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:

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