diff --git a/.claude/scripts/biome.json b/.claude/scripts/biome.json deleted file mode 100644 index 04280e36..00000000 --- a/.claude/scripts/biome.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "root": false, - "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", - "extends": ["../../biome.json"], - "vcs": { - "enabled": false - }, - "files": { - "ignoreUnknown": false, - "includes": ["*.ts"] - } -} diff --git a/.claude/skills/setup-spa/biome.json b/.claude/skills/setup-spa/biome.json deleted file mode 100644 index 14b0cb62..00000000 --- a/.claude/skills/setup-spa/biome.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "root": false, - "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", - "extends": ["../../../biome.json"], - "vcs": { - "enabled": false - }, - "files": { - "ignoreUnknown": false, - "includes": ["*.ts"] - } -} diff --git a/biome.json b/biome.json index 0f9be392..307d1642 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,15 @@ { "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true, + "defaultBranch": "main" + }, + "files": { + "ignoreUnknown": false, + "includes": ["packages/**/*.ts"] + }, "formatter": { "enabled": true, "indentStyle": "space", @@ -74,6 +84,31 @@ "bracketSameLine": false } }, + "overrides": [ + { + "includes": ["packages/cli/src/__tests__/**"], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off", + "noImplicitAnyLet": "off", + "noAssignInExpressions": "off" + }, + "correctness": { + "noUnusedVariables": "off", + "noUnusedFunctionParameters": "off" + } + } + } + } + ], + "plugins": [ + "./lint/no-type-assertion.grit", + "./lint/no-typeof-string-number.grit", + "./lint/no-try-catch.grit", + "./lint/no-try-finally.grit", + "./lint/no-ts-enum.grit" + ], "assist": { "actions": { "source": { diff --git a/lint/no-try-catch.grit b/lint/no-try-catch.grit index 9f09d5c0..1ddb2c5a 100644 --- a/lint/no-try-catch.grit +++ b/lint/no-try-catch.grit @@ -3,8 +3,7 @@ // $_ is an AST wildcard — it matches any subtree regardless of how many lines // it spans, so single-line and multiline try blocks are both caught. // -// shared/result.ts and shared/parse.ts are excluded because that is where -// tryCatch/asyncTryCatch are implemented, using actual try/catch internally. +// Files that legitimately need try/catch use biome-ignore comments. language js(typescript) or { @@ -13,8 +12,6 @@ or { `try { $_ } catch ($err) { $_ } finally { $_ }`, `try { $_ } catch { $_ } finally { $_ }` } as $expr where { - $filename <: not includes "shared/result.ts", - $filename <: not includes "shared/parse.ts", register_diagnostic( span = $expr, message = "Avoid try/catch — use tryCatch / asyncTryCatch from @openrouter/spawn-shared. Sync: const r = tryCatch(() => expr); if (!r.ok) { ... }. Async: const r = await asyncTryCatch(() => fn()); if (!r.ok) { ... }.", diff --git a/lint/no-try-finally.grit b/lint/no-try-finally.grit index 157c4853..c2672dd0 100644 --- a/lint/no-try-finally.grit +++ b/lint/no-try-finally.grit @@ -6,13 +6,10 @@ // Guidance: asyncTryCatch() never throws (it returns a Result), so cleanup // code can simply run sequentially on the next line — no nesting needed. // -// shared/result.ts and shared/parse.ts are excluded because that is where -// tryCatch/asyncTryCatch are implemented, using actual try/catch internally. +// Files that legitimately need try/finally use biome-ignore comments. language js(typescript) `try { $_ } finally { $_ }` as $expr where { - $filename <: not includes "shared/result.ts", - $filename <: not includes "shared/parse.ts", register_diagnostic( span = $expr, message = "Avoid try/finally — asyncTryCatch() from @openrouter/spawn-shared never throws, so cleanup just runs sequentially. Before: try { await fn(); } finally { cleanup(); }. After: await asyncTryCatch(() => fn()); cleanup();.", diff --git a/lint/no-ts-enum.grit b/lint/no-ts-enum.grit new file mode 100644 index 00000000..223e1eab --- /dev/null +++ b/lint/no-ts-enum.grit @@ -0,0 +1,9 @@ +language js(typescript) + +TsEnumDeclaration() as $decl where { + register_diagnostic( + span=$decl, + message="TypeScript `enum` is banned. Use a `const` object with `as const` and a `ValueOf` type instead.", + severity="error" + ) +} diff --git a/packages/cli/biome.json b/packages/cli/biome.json deleted file mode 100644 index bd6d0e63..00000000 --- a/packages/cli/biome.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "root": false, - "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", - "extends": ["../../biome.json"], - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true, - "defaultBranch": "main" - }, - "files": { - "ignoreUnknown": false, - "includes": ["src/**/*.ts"] - }, - "overrides": [ - { - "includes": ["src/__tests__/**"], - "linter": { - "rules": { - "suspicious": { - "noExplicitAny": "off", - "noImplicitAnyLet": "off", - "noAssignInExpressions": "off" - }, - "correctness": { - "noUnusedVariables": "off", - "noUnusedFunctionParameters": "off" - } - } - } - } - ], - "plugins": [ - "../../lint/no-type-assertion.grit", - "../../lint/no-typeof-string-number.grit", - "../../lint/no-try-catch.grit", - "../../lint/no-try-finally.grit" - ] -} diff --git a/packages/cli/build-clouds.ts b/packages/cli/build-clouds.ts index 7e22fe97..eb6a0404 100644 --- a/packages/cli/build-clouds.ts +++ b/packages/cli/build-clouds.ts @@ -1,4 +1,5 @@ #!/usr/bin/env bun + // Build bundled JS files for cloud providers that use TypeScript. // Each cloud with a cli/src/{cloud}/main.ts gets bundled into {cloud}.js. // These bundles are uploaded to GitHub releases for curl|bash execution. @@ -7,8 +8,8 @@ // bun run cli/build-clouds.ts # build all clouds // bun run cli/build-clouds.ts aws # build specific cloud -import { readdirSync, existsSync } from "fs"; -import path from "path"; +import { existsSync, readdirSync } from "node:fs"; +import path from "node:path"; const cliDir = path.dirname(new URL(import.meta.url).pathname); const srcDir = path.join(cliDir, "src"); @@ -24,7 +25,9 @@ async function buildCloud(cloud: string): Promise { console.log(`build: src/${cloud}/main.ts -> ${cloud}.js`); const result = await Bun.build({ - entrypoints: [entry], + entrypoints: [ + entry, + ], outdir: cliDir, naming: `${cloud}.js`, target: "bun", @@ -34,7 +37,9 @@ async function buildCloud(cloud: string): Promise { if (!result.success) { console.error(`FAIL: ${cloud}`); - for (const log of result.logs) console.error(" ", log); + for (const log of result.logs) { + console.error(" ", log); + } return false; } @@ -51,13 +56,23 @@ if (filter) { (await buildCloud(filter)) ? built++ : failed++; } else { // Auto-discover: any directory under src/ with a main.ts - for (const entry of readdirSync(srcDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - if (entry.name.startsWith("__")) continue; - if (!existsSync(path.join(srcDir, entry.name, "main.ts"))) continue; + for (const entry of readdirSync(srcDir, { + withFileTypes: true, + })) { + if (!entry.isDirectory()) { + continue; + } + if (entry.name.startsWith("__")) { + continue; + } + if (!existsSync(path.join(srcDir, entry.name, "main.ts"))) { + continue; + } (await buildCloud(entry.name)) ? built++ : failed++; } } console.log(`\n${built} built, ${failed} failed`); -if (failed > 0) process.exit(1); +if (failed > 0) { + process.exit(1); +} diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index b44a6c83..d50d2a6f 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -1,3 +1,4 @@ +import type { ValueOf } from "@openrouter/spawn-shared"; import type { SpawnRecord } from "../history.js"; import type { Manifest } from "../manifest.js"; @@ -242,8 +243,25 @@ export async function resolveListFilters( // ── Record actions ─────────────────────────────────────────────────────────── -/** Handle reconnect or rerun action for a selected spawn record */ -export async function handleRecordAction(selected: SpawnRecord, manifest: Manifest | null): Promise { +/** Outcome of handleRecordAction — determines whether the picker loops or exits. */ +export const RecordActionOutcome = { + /** Navigate back to the server list (delete/remove/cancel). */ + Back: 0, + /** Exit the picker (enter/reconnect/rerun). */ + Exit: 1, +} as const; + +export type RecordActionOutcome = ValueOf; + +/** + * Handle reconnect or rerun action for a selected spawn record. + * Returns Back if the picker should navigate back to the list (delete/remove), + * or Exit for terminal actions (enter/reconnect/rerun) that exit the picker. + */ +export async function handleRecordAction( + selected: SpawnRecord, + manifest: Manifest | null, +): Promise { if (!selected.connection) { // No connection info -- just rerun, reusing the existing spawn name if (selected.name) { @@ -251,7 +269,7 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife } p.log.step(`Spawning ${pc.bold(buildRecordLabel(selected, manifest))}`); await cmdRun(selected.agent, selected.cloud, selected.prompt); - return; + return RecordActionOutcome.Exit; } const conn = selected.connection; @@ -310,7 +328,7 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife }); if (p.isCancel(action)) { - handleCancel(); + return RecordActionOutcome.Back; } if (action === "enter") { @@ -322,7 +340,7 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife `VM may no longer be running. Use ${pc.cyan(`spawn ${selected.agent} ${selected.cloud}`)} to start a new one.`, ); } - return; + return RecordActionOutcome.Exit; } if (action === "reconnect") { @@ -334,12 +352,12 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife `VM may no longer be running. Use ${pc.cyan(`spawn ${selected.agent} ${selected.cloud}`)} to start a new one.`, ); } - return; + return RecordActionOutcome.Exit; } if (action === "delete") { await confirmAndDelete(selected, manifest); - return; + return RecordActionOutcome.Back; } if (action === "remove") { @@ -349,7 +367,7 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife } else { p.log.warn("Could not find record in history."); } - return; + return RecordActionOutcome.Back; } // Rerun (create new spawn). Clear any pre-set name so the user is prompted for @@ -360,6 +378,7 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife `Spawning ${pc.bold(buildRecordLabel(selected, manifest))} ${pc.dim(`(${buildRecordSubtitle(selected, manifest)})`)}`, ); await cmdRun(selected.agent, selected.cloud, selected.prompt); + return RecordActionOutcome.Exit; } /** Interactive picker with inline delete support. @@ -443,7 +462,18 @@ export async function activeServerPicker(records: SpawnRecord[], manifest: Manif } // action === "select" - await handleRecordAction(picked, manifest); + const outcome = await handleRecordAction(picked, manifest); + if (outcome === RecordActionOutcome.Back) { + // Delete/remove completed (or errored) — refresh the remaining list and loop back + const active = getActiveServers(); + const activeSet = new Set(active.map((r) => r.timestamp)); + for (let i = remaining.length - 1; i >= 0; i--) { + if (!activeSet.has(remaining[i].timestamp)) { + remaining.splice(i, 1); + } + } + continue; + } return; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6455c968..d0ceefac 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,6 @@ +export type { Result } from "./result"; +export type { ValueOf } from "./type-guards"; + export { parseJsonObj, parseJsonWith } from "./parse"; export { asyncTryCatch, @@ -8,7 +11,6 @@ export { isOperationalError, mapResult, Ok, - type Result, tryCatch, tryCatchIf, unwrapOr, diff --git a/packages/shared/src/parse.ts b/packages/shared/src/parse.ts index bf900eb2..59b7f13e 100644 --- a/packages/shared/src/parse.ts +++ b/packages/shared/src/parse.ts @@ -1,4 +1,5 @@ // shared/parse.ts — Schema-validated JSON parsing (replaces unsafe `as` casts) +// biome-ignore-all lint/plugin: parse implementations require raw try/catch around JSON.parse import * as v from "valibot"; diff --git a/packages/shared/src/result.ts b/packages/shared/src/result.ts index d3c83186..32617531 100644 --- a/packages/shared/src/result.ts +++ b/packages/shared/src/result.ts @@ -1,4 +1,5 @@ // shared/result.ts — Lightweight Result monad for retry-aware error handling. +// biome-ignore-all lint/plugin: this file implements tryCatch/asyncTryCatch and error predicates that require raw try/catch, typeof, and `as` // // Returning Err() signals a retryable failure; throwing signals a non-retryable one. // Used with withRetry() so callers decide at the point of failure whether an error diff --git a/packages/shared/src/type-guards.ts b/packages/shared/src/type-guards.ts index 06a2c5e1..a1720eff 100644 --- a/packages/shared/src/type-guards.ts +++ b/packages/shared/src/type-guards.ts @@ -1,6 +1,9 @@ // shared/type-guards.ts — Runtime type guards (replaces unsafe `as` casts on non-API values) // biome-ignore-all lint/plugin: type-guard implementations must use raw typeof +/** Extract union of all values from a const object or readonly tuple. */ +export type ValueOf = T extends readonly (infer U)[] ? U : T[keyof T]; + export function isString(val: unknown): val is string { return typeof val === "string"; }