diff --git a/.claude/skills/setup-spa/helpers.ts b/.claude/skills/setup-spa/helpers.ts index 2212d23b..78ef04fe 100644 --- a/.claude/skills/setup-spa/helpers.ts +++ b/.claude/skills/setup-spa/helpers.ts @@ -1,16 +1,15 @@ // SPA helpers — pure functions for parsing Claude Code stream events, // Slack formatting, state management (SQLite), and file download/cleanup. +import type { Result } from "@openrouter/spawn-shared"; import type { Block } from "@slack/bolt"; -import type { Result } from "../../../packages/cli/src/shared/result"; import { Database } from "bun:sqlite"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; +import { Err, isString, Ok, toRecord } from "@openrouter/spawn-shared"; import { slackifyMarkdown } from "slackify-markdown"; import * as v from "valibot"; -import { Err, Ok } from "../../../packages/cli/src/shared/result"; -import { isString, toRecord } from "../../../packages/cli/src/shared/type-guards"; // #region State — SQLite diff --git a/.claude/skills/setup-spa/main.ts b/.claude/skills/setup-spa/main.ts index 04268875..d9060104 100644 --- a/.claude/skills/setup-spa/main.ts +++ b/.claude/skills/setup-spa/main.ts @@ -5,9 +5,9 @@ import type { ActionsBlock, ContextBlock, KnownBlock, SectionBlock } from "@slac import type { Block } from "@slack/types"; import type { ToolCall } from "./helpers"; +import { isString, toRecord } from "@openrouter/spawn-shared"; import { App } from "@slack/bolt"; import * as v from "valibot"; -import { isString, toRecord } from "../../../packages/cli/src/shared/type-guards"; import { downloadSlackFile, findThread, diff --git a/.claude/skills/setup-spa/package.json b/.claude/skills/setup-spa/package.json index 42164eac..8192fcab 100644 --- a/.claude/skills/setup-spa/package.json +++ b/.claude/skills/setup-spa/package.json @@ -6,6 +6,7 @@ "start": "bun run main.ts" }, "dependencies": { + "@openrouter/spawn-shared": "workspace:*", "@slack/bolt": "4.6.0", "@slack/types": "^2.14.0", "@slack/web-api": "^7.14.1", diff --git a/.claude/skills/setup-spa/spa.test.ts b/.claude/skills/setup-spa/spa.test.ts index 08e1f2e4..0802e067 100644 --- a/.claude/skills/setup-spa/spa.test.ts +++ b/.claude/skills/setup-spa/spa.test.ts @@ -1,8 +1,8 @@ import type { ToolCall } from "./helpers"; import { afterEach, describe, expect, it, mock } from "bun:test"; +import { toRecord } from "@openrouter/spawn-shared"; import streamEvents from "../../../fixtures/claude-code/stream-events.json"; -import { toRecord } from "../../../packages/cli/src/shared/type-guards"; import { downloadSlackFile, extractMarkdownTables, diff --git a/bun.lock b/bun.lock index 3a4929d9..99e73fc3 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ ".claude/skills/setup-spa": { "name": "spawn-slack-bot", "dependencies": { + "@openrouter/spawn-shared": "workspace:*", "@slack/bolt": "4.6.0", "@slack/types": "^2.14.0", "@slack/web-api": "^7.14.1", @@ -22,12 +23,13 @@ }, "packages/cli": { "name": "@openrouter/spawn", - "version": "0.15.27", + "version": "0.15.32", "bin": { "spawn": "cli.js", }, "dependencies": { "@clack/prompts": "1.0.0", + "@openrouter/spawn-shared": "workspace:*", "picocolors": "1.1.1", "valibot": "1.2.0", }, @@ -36,6 +38,13 @@ "@types/bun": "1.3.8", }, }, + "packages/shared": { + "name": "@openrouter/spawn-shared", + "version": "0.2.0", + "dependencies": { + "valibot": "1.2.0", + }, + }, }, "packages": { "@biomejs/biome": ["@biomejs/biome@2.4.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.3", "@biomejs/cli-darwin-x64": "2.4.3", "@biomejs/cli-linux-arm64": "2.4.3", "@biomejs/cli-linux-arm64-musl": "2.4.3", "@biomejs/cli-linux-x64": "2.4.3", "@biomejs/cli-linux-x64-musl": "2.4.3", "@biomejs/cli-win32-arm64": "2.4.3", "@biomejs/cli-win32-x64": "2.4.3" }, "bin": { "biome": "bin/biome" } }, "sha512-cBrjf6PNF6yfL8+kcNl85AjiK2YHNsbU0EvDOwiZjBPbMbQ5QcgVGFpjD0O52p8nec5O8NYw7PKw3xUR7fPAkQ=="], @@ -62,6 +71,8 @@ "@openrouter/spawn": ["@openrouter/spawn@workspace:packages/cli"], + "@openrouter/spawn-shared": ["@openrouter/spawn-shared@workspace:packages/shared"], + "@slack/bolt": ["@slack/bolt@4.6.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^3.0.4", "@slack/socket-mode": "^2.0.5", "@slack/types": "^2.18.0", "@slack/web-api": "^7.12.0", "axios": "^1.12.0", "express": "^5.0.0", "path-to-regexp": "^8.1.0", "raw-body": "^3", "tsscmp": "^1.0.6" }, "peerDependencies": { "@types/express": "^5.0.0" } }, "sha512-xPgfUs2+OXSugz54Ky07pA890+Qydk22SYToi8uGpXeHSt1JWwFJkRyd/9Vlg5I1AdfdpGXExDpwnbuN9Q/2dQ=="], "@slack/logger": ["@slack/logger@4.0.0", "", { "dependencies": { "@types/node": ">=18.0.0" } }, "sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index 873c064e..e873c40e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@clack/prompts": "1.0.0", + "@openrouter/spawn-shared": "workspace:*", "picocolors": "1.1.1", "valibot": "1.2.0" }, diff --git a/packages/cli/src/shared/parse.ts b/packages/cli/src/shared/parse.ts index 34704942..17be0158 100644 --- a/packages/cli/src/shared/parse.ts +++ b/packages/cli/src/shared/parse.ts @@ -1,40 +1,9 @@ -// shared/parse.ts — Schema-validated JSON parsing (replaces unsafe `as` casts) +export { parseJsonObj, parseJsonWith } from "@openrouter/spawn-shared"; +// CLI-specific schema — not in shared package import * as v from "valibot"; -/** - * Parse a JSON string and validate it against a valibot schema. - * Returns the validated value, or null if parsing/validation fails. - */ -export function parseJsonWith>>( - text: string, - schema: T, -): v.InferOutput | null { - try { - return v.parse(schema, JSON.parse(text)); - } catch { - return null; - } -} - /** Schema for responses containing a `version` field (npm registry, GitHub releases). */ export const PkgVersionSchema = v.object({ version: v.string(), }); - -/** - * Parse a JSON string and return it as a Record or null. - * Rejects non-object results (arrays, primitives). - * Use for API responses that are always a JSON object. - */ -export function parseJsonObj(text: string): Record | null { - try { - const val = JSON.parse(text); - if (val !== null && typeof val === "object" && !Array.isArray(val)) { - return val; - } - return null; - } catch { - return null; - } -} diff --git a/packages/cli/src/shared/result.ts b/packages/cli/src/shared/result.ts index 2ed9fbd8..b384c139 100644 --- a/packages/cli/src/shared/result.ts +++ b/packages/cli/src/shared/result.ts @@ -1,33 +1 @@ -// shared/result.ts — Lightweight Result monad for retry-aware error handling. -// -// 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 -// is retryable (return Err) or fatal (throw), instead of relying on brittle -// error-message pattern matching after the fact. - -export type Result = - | { - ok: true; - data: T; - } - | { - ok: false; - error: Error; - }; -export const Ok = (data: T): Result => ({ - ok: true, - data, -}); -export const Err = (error: Error): Result => ({ - ok: false, - error, -}); - -/** Wrap a synchronous function call into a Result — no try/catch at the call site. */ -export function tryCatch(fn: () => T): Result { - try { - return Ok(fn()); - } catch (e) { - return Err(e instanceof Error ? e : new Error(String(e))); - } -} +export { Err, Ok, type Result, tryCatch } from "@openrouter/spawn-shared"; diff --git a/packages/cli/src/shared/type-guards.ts b/packages/cli/src/shared/type-guards.ts index 06a2c5e1..e946a938 100644 --- a/packages/cli/src/shared/type-guards.ts +++ b/packages/cli/src/shared/type-guards.ts @@ -1,47 +1 @@ -// 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 - -export function isString(val: unknown): val is string { - return typeof val === "string"; -} - -export function isNumber(val: unknown): val is number { - return typeof val === "number"; -} - -export function hasStatus(err: unknown): err is { - status: number; -} { - return err !== null && typeof err === "object" && "status" in err && typeof err.status === "number"; -} - -/** - * Extract a human-readable error message from an unknown caught value. - * Uses duck-typing instead of instanceof to avoid prototype chain issues. - */ -export function getErrorMessage(err: unknown): string { - return err && typeof err === "object" && "message" in err ? String(err.message) : String(err); -} - -/** - * Safely narrow an unknown value to a Record or return null. - */ -export function toRecord(val: unknown): Record | null { - if (val !== null && typeof val === "object" && !Array.isArray(val)) { - return val satisfies Record; - } - return null; -} - -/** - * Safely narrow an unknown value to an array of Record. - * Filters out non-object items. - */ -export function toObjectArray(val: unknown): Record[] { - if (!Array.isArray(val)) { - return []; - } - return val.filter( - (item): item is Record => item !== null && typeof item === "object" && !Array.isArray(item), - ); -} +export { getErrorMessage, hasStatus, isNumber, isString, toObjectArray, toRecord } from "@openrouter/spawn-shared"; diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 00000000..1f3a8de8 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,9 @@ +{ + "name": "@openrouter/spawn-shared", + "version": "0.2.0", + "type": "module", + "main": "src/index.ts", + "dependencies": { + "valibot": "1.2.0" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 00000000..0390ebd3 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,3 @@ +export { parseJsonObj, parseJsonWith } from "./parse"; +export { Err, Ok, type Result, tryCatch } from "./result"; +export { getErrorMessage, hasStatus, isNumber, isString, toObjectArray, toRecord } from "./type-guards"; diff --git a/packages/shared/src/parse.ts b/packages/shared/src/parse.ts new file mode 100644 index 00000000..bf900eb2 --- /dev/null +++ b/packages/shared/src/parse.ts @@ -0,0 +1,35 @@ +// shared/parse.ts — Schema-validated JSON parsing (replaces unsafe `as` casts) + +import * as v from "valibot"; + +/** + * Parse a JSON string and validate it against a valibot schema. + * Returns the validated value, or null if parsing/validation fails. + */ +export function parseJsonWith>>( + text: string, + schema: T, +): v.InferOutput | null { + try { + return v.parse(schema, JSON.parse(text)); + } catch { + return null; + } +} + +/** + * Parse a JSON string and return it as a Record or null. + * Rejects non-object results (arrays, primitives). + * Use for API responses that are always a JSON object. + */ +export function parseJsonObj(text: string): Record | null { + try { + const val = JSON.parse(text); + if (val !== null && typeof val === "object" && !Array.isArray(val)) { + return val; + } + return null; + } catch { + return null; + } +} diff --git a/packages/shared/src/result.ts b/packages/shared/src/result.ts new file mode 100644 index 00000000..2ed9fbd8 --- /dev/null +++ b/packages/shared/src/result.ts @@ -0,0 +1,33 @@ +// shared/result.ts — Lightweight Result monad for retry-aware error handling. +// +// 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 +// is retryable (return Err) or fatal (throw), instead of relying on brittle +// error-message pattern matching after the fact. + +export type Result = + | { + ok: true; + data: T; + } + | { + ok: false; + error: Error; + }; +export const Ok = (data: T): Result => ({ + ok: true, + data, +}); +export const Err = (error: Error): Result => ({ + ok: false, + error, +}); + +/** Wrap a synchronous function call into a Result — no try/catch at the call site. */ +export function tryCatch(fn: () => T): Result { + try { + return Ok(fn()); + } catch (e) { + return Err(e instanceof Error ? e : new Error(String(e))); + } +} diff --git a/packages/shared/src/type-guards.ts b/packages/shared/src/type-guards.ts new file mode 100644 index 00000000..06a2c5e1 --- /dev/null +++ b/packages/shared/src/type-guards.ts @@ -0,0 +1,47 @@ +// 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 + +export function isString(val: unknown): val is string { + return typeof val === "string"; +} + +export function isNumber(val: unknown): val is number { + return typeof val === "number"; +} + +export function hasStatus(err: unknown): err is { + status: number; +} { + return err !== null && typeof err === "object" && "status" in err && typeof err.status === "number"; +} + +/** + * Extract a human-readable error message from an unknown caught value. + * Uses duck-typing instead of instanceof to avoid prototype chain issues. + */ +export function getErrorMessage(err: unknown): string { + return err && typeof err === "object" && "message" in err ? String(err.message) : String(err); +} + +/** + * Safely narrow an unknown value to a Record or return null. + */ +export function toRecord(val: unknown): Record | null { + if (val !== null && typeof val === "object" && !Array.isArray(val)) { + return val satisfies Record; + } + return null; +} + +/** + * Safely narrow an unknown value to an array of Record. + * Filters out non-object items. + */ +export function toObjectArray(val: unknown): Record[] { + if (!Array.isArray(val)) { + return []; + } + return val.filter( + (item): item is Record => item !== null && typeof item === "object" && !Array.isArray(item), + ); +}