diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95948c0..a368c82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,8 +45,11 @@ jobs: with: node-version: "22" + # 2.1.287 is the first release with mods generally available: older + # validators reject the manifest's `types` contract, though older clients + # still load the plugin and ignore its hooks module. - name: Install Claude Code CLI - run: npm install --global --no-fund --no-audit @anthropic-ai/claude-code@2.1.224 + run: npm install --global --no-fund --no-audit @anthropic-ai/claude-code@2.1.287 - name: Validate plugin and marketplace manifests env: @@ -54,3 +57,8 @@ jobs: run: | claude plugin validate ./plugins/sprites --strict claude plugin validate . --strict + + - name: Test the Sprite Inspector mod + env: + CLAUDE_CONFIG_DIR: ${{ runner.temp }}/claude-config + run: claude plugin test ./plugins/sprites diff --git a/README.md b/README.md index 8d203a4..34c6d47 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Use [Sprites](https://sprites.dev) from Claude Code as persistent, isolated Linux development environments for builds, tests, sandboxes, previews, and long-running services. -This repository is a Claude Code plugin marketplace containing the `sprites` plugin. The plugin bundles the hosted Sprites MCP server, browser OAuth, workflow skills, explicit status and smoke-test commands, and confirmation hooks for risky remote operations. No Sprites CLI is required. +This repository is a Claude Code plugin marketplace containing the `sprites` plugin. The plugin bundles the hosted Sprites MCP server, browser OAuth, workflow skills, explicit status and smoke-test commands, confirmation hooks for risky remote operations, and a read-only Sprite Inspector pane. No Sprites CLI is required. ## Install @@ -43,6 +43,7 @@ An empty Sprite list means the integration is authenticated and working. - Automatic workflow guidance for creating, inspecting, and operating Sprites. - `/sprites:status` for a read-only integration and authentication check. - `/sprites:smoke` for list → create → exec → approved cleanup. +- `/sprites-inspector` for a read-only pane to browse Sprites, their services, checkpoints, and logs, and to point Claude at one. - Confirmation prompts before destroying a Sprite, restoring a checkpoint, replacing the network policy, or serving a new service on the Sprite's URL. - Checkpoint prompts before risky remote package installs, migrations, or broad destructive commands. @@ -65,6 +66,35 @@ Claude Code remains on the local machine. The plugin's MCP server is the control There is no dedicated MCP file-upload tool. Prefer cloning a repository into the Sprite. For small generated files, the bundled skill documents a base64 transfer pattern that avoids fragile shell quoting. +## Sprite Inspector + +`/sprites-inspector [name prefix]` opens a pane beside the conversation in the terminal and in the Code tab of the Claude desktop app. It is a [Claude Code mod](https://code.claude.com/docs/en/plugins/mods/overview), the counterpart of the Inspector the hosted server offers MCP App hosts, and calls the same read-only tools over the plugin's own MCP connection: + +- Browse and filter Sprites by name prefix (`open_sprite_inspector`), 20 at a time. +- Select a Sprite to see its organization, creation date, status, and URL (`get_sprite_info`, which does not wake it). +- Load its services and checkpoints (`service_list`, `checkpoint_list`) and the last 100 lines of a service's logs (`service_logs`). These calls may wake a cold Sprite, so they run only when asked. +- **Use in chat** tells Claude which Sprite you mean, with its `sprite_id`, so later calls are checked against that exact Sprite rather than a deleted and recreated one with the same name. + +The pane never changes a Sprite. It lists again when it is opened and when it regains focus, and does not poll. + +Mods need Claude Code 2.1.287 or later. Older versions load the rest of the plugin and skip the pane. Where nothing can draw a pane, such as the VS Code extension's chat panel or `claude -p`, the command answers with a text listing instead. + +In auto mode, Claude Code puts the pane's MCP calls to the auto-mode classifier, which has no request of yours to judge a button press by, and refuses them. Allow the pane's read-only tools in your settings to use it there: + +```json +{ + "permissions": { + "allow": [ + "mcp__plugin_sprites_sprites__open_sprite_inspector", + "mcp__plugin_sprites_sprites__get_sprite_info", + "mcp__plugin_sprites_sprites__service_list", + "mcp__plugin_sprites_sprites__checkpoint_list", + "mcp__plugin_sprites_sprites__service_logs" + ] + } +} +``` + ## OAuth restrictions Restricted connector tokens use a non-empty Sprite-name prefix and may limit how many Sprites the connector can create. The usual default is `mcp-`, but Claude learns the actual rule from API responses rather than assuming it. @@ -105,8 +135,11 @@ python3 scripts/check_repository.py python3 -m unittest discover -s tests -v claude plugin validate ./plugins/sprites claude plugin validate . +claude plugin test ./plugins/sprites ``` +`claude plugin test` runs the Sprite Inspector's tests against a stand-in for the MCP server, so it needs no network or sign-in. + ## Repository layout ```text @@ -114,7 +147,10 @@ claude plugin validate . plugins/sprites/ .claude-plugin/plugin.json Plugin manifest .mcp.json Hosted MCP configuration - hooks/hooks.json Claude Code safety hook + hooks/hooks.json Claude Code safety hook and mod registration + hooks/register.tsx Sprite Inspector mod + types/index.d.ts The mod's state contract + tests/ Sprite Inspector mod tests scripts/sprites_guard.py Dependency-free hook implementation skills/ Workflow skills and references ``` diff --git a/plugins/sprites/.claude-plugin/plugin.json b/plugins/sprites/.claude-plugin/plugin.json index c518bc9..96bf3a4 100644 --- a/plugins/sprites/.claude-plugin/plugin.json +++ b/plugins/sprites/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "sprites", "displayName": "Sprites", - "version": "0.1.0", + "version": "0.2.0", "description": "Use Sprites from Claude Code to create, inspect, and operate remote isolated development environments.", "author": { "name": "Fly.io", @@ -12,6 +12,7 @@ "homepage": "https://sprites.dev", "repository": "https://github.com/superfly/sprites-claude-plugin", "license": "MIT", + "types": "./types/index.d.ts", "keywords": [ "sprites", "development-environments", diff --git a/plugins/sprites/README.md b/plugins/sprites/README.md index 294a9de..9bc4073 100644 --- a/plugins/sprites/README.md +++ b/plugins/sprites/README.md @@ -4,7 +4,7 @@ The Sprites plugin gives Claude Code hosted MCP access to persistent, isolated development environments, plus skills and confirmation hooks for safe remote workflows. -After installation, run `/sprites:status` to list visible Sprites and complete OAuth if needed. Run `/sprites:smoke` for an end-to-end list, create, and exec check with cleanup only after approval. +After installation, run `/sprites:status` to list visible Sprites and complete OAuth if needed. Run `/sprites:smoke` for an end-to-end list, create, and exec check with cleanup only after approval. Run `/sprites-inspector` to browse Sprites, their services, checkpoints, and logs in a read-only pane, and to point Claude at one. The local Claude Code workspace and the remote Sprite filesystem are separate. Use the plugin-provided MCP tools for Sprite commands, services, checkpoints, and policy. Do not install the Sprites CLI or register a second MCP server for normal plugin use. diff --git a/plugins/sprites/hooks/hooks.json b/plugins/sprites/hooks/hooks.json index db7be47..406d531 100644 --- a/plugins/sprites/hooks/hooks.json +++ b/plugins/sprites/hooks/hooks.json @@ -12,5 +12,6 @@ ] } ] - } + }, + "modules": ["./register.tsx"] } diff --git a/plugins/sprites/hooks/register.tsx b/plugins/sprites/hooks/register.tsx new file mode 100644 index 0000000..b86456e --- /dev/null +++ b/plugins/sprites/hooks/register.tsx @@ -0,0 +1,654 @@ +import { atom, read, update } from 'claude-code' +import type { EngineInterface, McpToolResult, Register } from 'claude-code' + +import type { CheckpointRow, InspectorState, Section, ServiceRow, SpriteSummary } from '../types' + +// A read-only Sprite Inspector: the Claude Code counterpart of the MCP App the +// hosted server serves at ui://sprites/inspector.html. It calls the same tools +// over the session's own connection, so it needs no credentials of its own. + +const PANE = 'sprites-inspector' +const COMMAND = 'sprites-inspector' +const SERVER = 'sprites' +const LOG_LINES = 100 +const LOG_CHARS = 10000 +// Focus that returns sooner than this after a listing does not list again. +const REFRESH_GAP_MS = 10_000 + +// From this many columns the list and the details sit side by side. +const WIDE_COLUMNS = 64 +const LIST_COLUMNS = 30 +const LABEL_COLUMNS = 8 +// Room for the longest checkpoint id, `Current`. +const CHECKPOINT_ID_COLUMNS = 8 + +// Theme keys, so the colors follow the person's light or dark theme. +const STATUS_COLORS: Record = { running: 'success', warm: 'warning', cold: 'inactive' } + +const section = (state: Section['state'], items: T[] = [], message = ''): Section => ({ state, items, message }) +const idle = (): Section => section('idle') + +const CLEARED = { + selected: null, + isChecking: false, + services: idle(), + checkpoints: idle(), + logs: null, +} satisfies Partial + +const inspector = atom({ plugin: 'sprites', key: 'inspector' } as const, { + status: '', + statusTone: 'info', + prefix: '', + sprites: [], + cursor: null, + listedAt: null, + isListing: false, + attachedId: null, + ...CLEARED, +} as InspectorState) + +// Each counter discards answers that arrive after a newer request of its kind. +let listRun = 0 +let selectRun = 0 +let runtimeRun = 0 +let logsRun = 0 +let server: string | undefined +// The pane's focus as the latest drawing saw it, to notice it coming back. +let wasFocused = false + +async function connection($: EngineInterface): Promise { + if (server) return server + const connected = await $.mcp.connect(SERVER) + if (!connected.isConnected) { + throw new Error( + connected.reason === 'auth' + ? 'The Sprites MCP server needs sign-in. Authenticate it in /mcp, then press Find again.' + : connected.message, + ) + } + server = connected.server + return server +} + +async function call($: EngineInterface, tool: string, args: Record): Promise { + let result: McpToolResult + try { + result = await $.mcp.call(await connection($), tool, args) + } catch (error) { + server = undefined + throw error + } + if (result.isError) throw new Error(textOf(result) || `${tool} failed.`) + return result +} + +function textOf(result: McpToolResult): string { + return result.content + .filter(block => block.type === 'text') + .map(block => block.text ?? '') + .join('\n') +} + +// Extension tools return structuredContent; the generated runtime tools return JSON as text. +function dataOf(result: McpToolResult): unknown { + return result.structuredContent ?? JSON.parse(textOf(result)) +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +const info = (status: string) => ({ status, statusTone: 'info' as const }) +const failure = (error: unknown) => ({ status: errorText(error), statusTone: 'error' as const }) + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} +} + +function str(value: unknown): string { + return typeof value === 'string' ? value : typeof value === 'number' ? String(value) : '' +} + +// A bare array, or an object holding one under `key`. +function listOf(value: unknown, key: string, what: string): unknown[] { + const list = Array.isArray(value) ? value : record(value)[key] + if (!Array.isArray(list)) throw new Error(`Invalid ${what} response.`) + return list +} + +function summary(value: unknown): SpriteSummary | null { + const v = record(value) + if (!str(v.id) || !str(v.name)) return null + return { + id: str(v.id), + name: str(v.name), + organization: str(v.organization), + orgId: str(v.org_id), + url: str(v.url), + status: str(v.status), + version: str(v.version), + created: str(v.created_at), + uri: str(record(v.resource).uri), + } +} + +function safeUrl(value: string): string | null { + try { + const url = new URL(value) + return url.protocol === 'https:' && !url.username && !url.password ? url.href : null + } catch { + return null + } +} + +// Logs can carry terminal escapes; Code and Text take tab and newline as their only control characters. +function printable(text: string): string { + return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '') +} + +const day = (iso: string) => iso.slice(0, 10) + +function timeOfDay(ms: number): string { + try { + return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + } catch { + return new Date(ms).toISOString().slice(11, 16) + } +} +const statusColor = (status: string) => STATUS_COLORS[status] ?? 'warning' + +async function loadPage($: EngineInterface, append: boolean, nextPrefix?: string): Promise { + const before = await read($, inspector) + // Ignore a queued Load more press after another search has reset its cursor. + if (append && (before.isListing || !before.cursor)) return + const run = ++listRun + const prefix = append ? before.prefix : (nextPrefix ?? before.prefix).trim() + if (!append) { + ++selectRun + ++runtimeRun + ++logsRun + } + await update($, inspector, s => ({ + ...s, + ...(append ? {} : { ...CLEARED, prefix, sprites: [], cursor: null, listedAt: null }), + ...info(''), + isListing: true, + })) + try { + const page = record( + dataOf(await call($, 'open_sprite_inspector', { prefix, continuation_token: append ? (before.cursor ?? '') : '' })), + ) + const incoming = listOf(page, 'sprites', 'Sprite listing') + .map(summary) + .filter((one): one is SpriteSummary => one !== null) + const listedAt = await $.clock.now() + if (run !== listRun) return + await update($, inspector, s => { + const sprites = append ? [...s.sprites, ...incoming.filter(one => !s.sprites.some(old => old.id === one.id))] : incoming + return { + ...s, + ...info(sprites.length ? '' : 'No matching Sprites.'), + sprites, + cursor: str(page.next_continuation_token) || null, + listedAt, + prefix: typeof page.prefix === 'string' ? page.prefix : s.prefix, + isListing: false, + } + }) + } catch (error) { + if (run === listRun) await update($, inspector, s => ({ ...s, ...failure(error), isListing: false })) + } +} + +// The quiet re-list the pane runs when it regains focus: it changes only what +// the listing says now, and keeps the selection, runtime details and logs. +async function refresh($: EngineInterface): Promise { + const before = await read($, inspector) + if (before.isListing || before.listedAt === null) return + if ((await $.clock.now()) - before.listedAt < REFRESH_GAP_MS) return + const run = ++listRun + try { + const page = record(dataOf(await call($, 'open_sprite_inspector', { prefix: before.prefix, continuation_token: '' }))) + const fresh = listOf(page, 'sprites', 'Sprite listing') + .map(summary) + .filter((one): one is SpriteSummary => one !== null) + const listedAt = await $.clock.now() + if (run !== listRun) return + const byId = new Map(fresh.map(one => [one.id, one])) + await update($, inspector, s => { + // Restart pagination with this page's cursor: inserts or deletions can + // move rows across the old page boundaries while the pane is unfocused. + const listed = s.selected && byId.get(s.selected.id) + return { + ...s, + ...(s.statusTone === 'error' ? info('') : {}), + sprites: fresh, + cursor: str(page.next_continuation_token) || null, + listedAt, + selected: s.selected && listed ? { ...s.selected, status: listed.status } : s.selected, + } + }) + } catch (error) { + if (run === listRun) await update($, inspector, s => ({ ...s, ...failure(error) })) + } +} + +async function selectSprite($: EngineInterface, sprite: SpriteSummary): Promise { + const run = ++selectRun + ++runtimeRun + ++logsRun + await update($, inspector, s => ({ ...s, ...CLEARED, ...info(''), selected: sprite, isChecking: true })) + try { + const checked = summary(dataOf(await call($, 'get_sprite_info', { sprite: sprite.name, sprite_id: sprite.id }))) + if (!checked) throw new Error('Invalid Sprite info response.') + if (run !== selectRun) return + // get_sprite_info's status can disagree with the listing's live runtime + // state (a running Sprite reads cold), so the listing's status stands. + const fresh = { ...checked, status: sprite.status || checked.status } + await update($, inspector, s => ({ + ...s, + selected: fresh, + isChecking: false, + sprites: s.sprites.map(one => (one.id === fresh.id ? fresh : one)), + })) + } catch (error) { + if (run === selectRun) await update($, inspector, s => ({ ...s, ...CLEARED, ...failure(error) })) + } +} + +// The counterpart of the MCP App's ui/update-model-context: a row the model +// reads and the person does not see as typed. Rows add up rather than replace, +// so the newest attachment is the one that counts. +async function attach($: EngineInterface): Promise { + const { selected } = await read($, inspector) + if (!selected) return + const identity = { + sprite: selected.name, + sprite_id: selected.id, + org_id: selected.orgId, + organization: selected.organization, + uri: selected.uri, + } + const text = + `The user selected this Sprite in the Sprite Inspector pane: ${JSON.stringify(identity)}. ` + + 'Treat it as the target when they refer to "the Sprite" or "this Sprite". Verify the identity with get_sprite_info ' + + 'before operating on it, and pass sprite_id alongside sprite on Sprite-scoped tool calls.' + let refused: string | undefined + try { + refused = (await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })).deny + } catch (error) { + refused = errorText(error) + } + await update($, inspector, s => + refused + ? { ...s, ...failure(`Could not attach the Sprite: ${refused}`) } + : { ...s, ...info(''), attachedId: selected.id }, + ) +} + +async function loadRuntime($: EngineInterface): Promise { + const { selected } = await read($, inspector) + if (!selected) return + const selection = selectRun + const run = ++runtimeRun + const current = () => selection === selectRun && run === runtimeRun + await update($, inspector, s => ({ ...s, services: section('loading'), checkpoints: section('loading') })) + const args = { sprite: selected.name, sprite_id: selected.id } + + await Promise.all([ + (async () => { + try { + const items = listOf(dataOf(await call($, 'service_list', args)), 'services', 'services').map(one => { + const v = record(one) + return { name: str(v.name), status: str(record(v.state).status) || 'unknown' } + }) + if (current()) await update($, inspector, s => ({ ...s, services: section('ready', items) })) + } catch (error) { + if (current()) await update($, inspector, s => ({ ...s, services: section('error', [], errorText(error)) })) + } + })(), + (async () => { + try { + const items = listOf(dataOf(await call($, 'checkpoint_list', args)), 'checkpoints', 'checkpoints').map(one => { + const v = record(one) + return { id: str(v.id), comment: str(v.comment), created: str(v.create_time) } + }) + if (current()) await update($, inspector, s => ({ ...s, checkpoints: section('ready', items) })) + } catch (error) { + if (current()) await update($, inspector, s => ({ ...s, checkpoints: section('error', [], errorText(error)) })) + } + })(), + ]) +} + +async function loadLogs($: EngineInterface, service: string): Promise { + const { selected } = await read($, inspector) + if (!selected) return + const selection = selectRun + const run = ++logsRun + const current = () => selection === selectRun && run === logsRun + await update($, inspector, s => ({ ...s, logs: { service, state: 'loading' as const, text: '' } })) + try { + const result = await call($, 'service_logs', { + sprite: selected.name, + sprite_id: selected.id, + service_name: service, + lines: LOG_LINES, + duration: '0s', + }) + // The endpoint streams NDJSON events; `data` carries the output itself. + const text = printable( + textOf(result) + .split('\n') + .filter(Boolean) + .map(line => { + try { + const event = record(JSON.parse(line)) + return typeof event.data === 'string' ? event.data : event.type === 'error' ? `${JSON.stringify(event)}\n` : '' + } catch { + return `${line}\n` + } + }) + .join(''), + ) + .replace(/\n+$/, '') + .slice(-LOG_CHARS) + if (current()) await update($, inspector, s => ({ ...s, logs: { service, state: 'ready' as const, text } })) + } catch (error) { + if (current()) await update($, inspector, s => ({ ...s, logs: { service, state: 'error' as const, text: errorText(error) } })) + } +} + +// MCP calls can outlast the dispatch that asked for them; a timer runs them on their own. +function later($: EngineInterface, work: () => Promise): void { + $.clock.after(0, () => void work()) +} + +function listing(s: InspectorState): string { + if (!s.sprites.length) return s.status || 'No Sprites loaded.' + const rows = s.sprites.map(one => `- ${one.name} · ${one.status || 'unknown'}${one.organization ? ` · ${one.organization}` : ''}`) + return [`${s.sprites.length} Sprite${s.sprites.length === 1 ? '' : 's'}${s.cursor ? ' (more available)' : ''}`, ...rows].join('\n') +} + +export const register: Register = on => { + on('session.start', async ($, e, next) => { + await $.command.register({ + name: COMMAND, + description: 'Browse your Sprites, their services, checkpoints, and logs in a read-only pane', + argumentHint: '[name prefix]', + }) + // State outlives a reload or resume, but the requests in flight did not. + await update($, inspector, s => ({ + ...s, + ...info(''), + isListing: false, + isChecking: false, + services: s.services.state === 'loading' ? idle() : s.services, + checkpoints: s.checkpoints.state === 'loading' ? idle() : s.checkpoints, + logs: s.logs?.state === 'loading' ? null : s.logs, + })) + return next(e) + }) + + on('command.run', { command: COMMAND }, async ($, e) => { + const opened = await $.ui.open({ id: PANE, title: 'Sprites' }) + const before = await read($, inspector) + const prefix = e.args.trim() + + // Where no pane can draw (the VS Code panel, claude -p), answer in text. + if (!opened.isPlaced) { + await loadPage($, false, prefix) + return { text: listing(await read($, inspector)) } + } + // Opening always lists afresh: statuses change while the pane sits idle. + if (!before.isListing || prefix) later($, () => loadPage($, false, prefix || undefined)) + return { text: 'Sprite Inspector opened.' } + }) + + on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => { + const { Box, Text, Button, Link, Code } = $.ui.resolve(e) + const Input = e.surface === 'mobile' ? undefined : $.ui.resolve(e).Input + // Coming back to the pane lists again; a drawing only schedules it, since it may not write. + if (e.props.isFocused && !wasFocused) later($, () => refresh($)) + wasFocused = e.props.isFocused + + const s = await read($, inspector) + const selected = s.selected + const isWide = e.props.bodyColumns >= WIDE_COLUMNS + const hasRuntime = s.services.state !== 'idle' || s.checkpoints.state !== 'idle' + const isRuntimeLoading = s.services.state === 'loading' || s.checkpoints.state === 'loading' + + const field = (label: string, value: string) => ( + + + {label} + + {value} + + ) + + const heading = (title: string, detail?: string) => ( + + {title} + {detail ? {detail} : null} + + ) + + const search = ( + + {Input && ( + later($, () => loadPage($, false, value))} + /> + )} + {s.isListing && Loading Sprites…} + {s.status && ( + + {s.status} + + )} + + ) + + const list = ( + + {s.sprites.length > 0 && + heading( + 'Sprites', + `${s.sprites.length}${s.cursor ? '+' : ''}${s.listedAt !== null ? ` · as of ${timeOfDay(s.listedAt)}` : ''}`, + )} + {!s.sprites.length && !s.isListing && !s.status && ( + Press Find to list your Sprites, or filter by a name prefix. + )} + {s.sprites.map(one => { + const isSelected = one.id === selected?.id + return ( + + {isSelected ? '▌' : ' '} + ● + + {/* The selected row stays a Button: a focused element that leaves the + tree takes the pane's focus with it, and on desktop the next click + into an unfocused pane only focuses. */} +