From f5f020d250c279103530b032666450e69205b056 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:48:51 -0800 Subject: [PATCH] feat: Result monad in shared + Claude Code fixtures + SPA Result adoption (#1858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: split SPA into helpers + main, add build script and tests Split slack-bot.ts into helpers.ts (pure functions) and main.ts (entry point) for testability. Add build.ts to bundle SPA into spa.js. Add spa.test.ts with 19 tests covering stream parsing and text helpers. Improved streaming: tool_use and tool_result events get their own Slack messages instead of concatenating everything into one. Prompt is passed via stdin to avoid CLI flag parsing issues with user content. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: drop build.ts — run main.ts directly via bun Bun runs TypeScript natively, no bundling step needed. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: move Result monad to shared, add Claude Code fixtures, use Result in SPA - Move Result type/Ok/Err from packages/cli/src/shared/result.ts to packages/shared/src/result.ts and re-export from @openrouter/spawn-shared - Update CLI imports (ui.ts) to use the shared package - Add fixtures/claude-code/ with realistic stream-json events covering all event types (assistant text, tool_use, user tool_result, result) - Refactor SPA helpers to return Result instead of throwing/returning null: loadState() → Result, saveState() → Result, downloadSlackFile() → Result, addMapping() → Result - Update main.ts call sites to handle Result returns - Update SPA tests to import events from fixtures and test Result returns - Bump CLI version 0.10.0 → 0.10.1 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: biome format issues in aws.test.ts, aws.ts, daytona.ts Expand inline objects/arrays to multi-line format to satisfy biome formatter rules. No logic changes. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Co-authored-by: Claude Opus 4.6 (1M context) --- .claude/skills/setup-spa/SKILL.md | 6 +- .claude/skills/setup-spa/biome.json | 11 - .claude/skills/setup-spa/helpers.ts | 285 ++++++++++++ .../setup-spa/{slack-bot.ts => main.ts} | 432 +++++------------- .claude/skills/setup-spa/package.json | 2 +- .claude/skills/setup-spa/spa.test.ts | 275 +++++++++++ fixtures/claude-code/_metadata.json | 7 + fixtures/claude-code/stream-events.json | 107 +++++ packages/cli/package.json | 2 +- packages/cli/src/__tests__/aws.test.ts | 63 ++- packages/cli/src/aws/aws.ts | 33 +- packages/cli/src/daytona/daytona.ts | 1 - packages/cli/src/shared/ui.ts | 4 +- packages/shared/src/index.ts | 1 + .../{cli/src/shared => shared/src}/result.ts | 0 15 files changed, 882 insertions(+), 347 deletions(-) delete mode 100644 .claude/skills/setup-spa/biome.json create mode 100644 .claude/skills/setup-spa/helpers.ts rename .claude/skills/setup-spa/{slack-bot.ts => main.ts} (52%) create mode 100644 .claude/skills/setup-spa/spa.test.ts create mode 100644 fixtures/claude-code/_metadata.json create mode 100644 fixtures/claude-code/stream-events.json rename packages/{cli/src/shared => shared/src}/result.ts (100%) diff --git a/.claude/skills/setup-spa/SKILL.md b/.claude/skills/setup-spa/SKILL.md index 8312886a..64749a43 100644 --- a/.claude/skills/setup-spa/SKILL.md +++ b/.claude/skills/setup-spa/SKILL.md @@ -60,15 +60,15 @@ export SLACK_BOT_TOKEN="xoxb-YOUR-BOT-TOKEN" export SLACK_APP_TOKEN="xapp-YOUR-APP-TOKEN" export SLACK_CHANNEL_ID="C0000000000" export GITHUB_REPO="OpenRouterTeam/spawn" -export REPO_ROOT="/home/lab/spawn" +export REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" -exec bun run "${SCRIPT_DIR}/slack-bot.ts" +exec bun run "${SCRIPT_DIR}/main.ts" ``` ## Install ```bash -cd .claude/skills/setup-spa && bun install +bun install # from repo root — workspace install ``` ## Systemd Service diff --git a/.claude/skills/setup-spa/biome.json b/.claude/skills/setup-spa/biome.json deleted file mode 100644 index 2f90f28c..00000000 --- a/.claude/skills/setup-spa/biome.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.4.3/schema.json", - "extends": ["../../../lint/biome.json"], - "vcs": { - "enabled": false - }, - "files": { - "includes": ["**/*.ts"] - }, - "plugins": ["../../../lint/no-type-assertion.grit"] -} diff --git a/.claude/skills/setup-spa/helpers.ts b/.claude/skills/setup-spa/helpers.ts new file mode 100644 index 00000000..bafd2ee8 --- /dev/null +++ b/.claude/skills/setup-spa/helpers.ts @@ -0,0 +1,285 @@ +// SPA helpers — pure functions for parsing Claude Code stream events, +// Slack formatting, state management, and file download/cleanup. + +import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync, statSync } from "node:fs"; +import { dirname } from "node:path"; +import * as v from "valibot"; +import { toRecord, type Result, Ok, Err } from "@openrouter/spawn-shared"; + +// #region State + +const STATE_PATH = process.env.STATE_PATH ?? `${process.env.HOME ?? "/root"}/.config/spawn/slack-issues.json`; + +const MappingSchema = v.object({ + channel: v.string(), + threadTs: v.string(), + sessionId: v.string(), + createdAt: v.string(), +}); + +const StateSchema = v.object({ + mappings: v.array(MappingSchema), +}); + +export type Mapping = v.InferOutput; +export type State = v.InferOutput; + +export function loadState(): Result { + try { + if (!existsSync(STATE_PATH)) { + return Ok({ mappings: [] }); + } + const raw = readFileSync(STATE_PATH, "utf-8"); + const parsed = v.parse(StateSchema, JSON.parse(raw)); + return Ok(parsed); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return Err(new Error(`Failed to load state: ${msg}`)); + } +} + +export function saveState(s: State): Result { + try { + const dir = dirname(STATE_PATH); + mkdirSync(dir, { recursive: true }); + writeFileSync(STATE_PATH, `${JSON.stringify(s, null, 2)}\n`); + return Ok(undefined); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return Err(new Error(`Failed to save state: ${msg}`)); + } +} + +export function findMapping(s: State, channel: string, threadTs: string): Mapping | undefined { + return s.mappings.find((m) => m.channel === channel && m.threadTs === threadTs); +} + +export function addMapping(s: State, mapping: Mapping): Result { + s.mappings.push(mapping); + return saveState(s); +} + +// #endregion + +// #region Claude Code stream parsing + +export const ResultSchema = v.object({ + type: v.literal("result"), + session_id: v.string(), +}); + +export interface SlackSegment { + kind: "text" | "tool_use" | "tool_result"; + text: string; +} + +/** + * Parse a Claude Code stream-json event into a typed Slack segment. + * + * Claude Code emits complete messages (not Anthropic streaming deltas): + * {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}} + * {"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{...}}]}} + * {"type":"user","message":{"content":[{"type":"tool_result","content":"..."}]}} + * {"type":"result","result":"...","session_id":"..."} + */ +export function parseStreamEvent(event: Record): SlackSegment | null { + const type = event.type; + + if (type === "assistant") { + const msg = toRecord(event.message); + if (!msg) { + return null; + } + const content = Array.isArray(msg.content) ? msg.content : []; + + // Check what kind of content blocks this message has + const textParts: string[] = []; + const toolParts: string[] = []; + + for (const rawBlock of content) { + const block = toRecord(rawBlock); + if (!block) { + continue; + } + + if (block.type === "text" && typeof block.text === "string") { + textParts.push(block.text); + } + + if (block.type === "tool_use" && typeof block.name === "string") { + const input = toRecord(block.input); + let summary = ""; + if (input) { + const hint = + (typeof input.command === "string" ? input.command : null) ?? + (typeof input.pattern === "string" ? input.pattern : null) ?? + (typeof input.file_path === "string" ? input.file_path : null); + if (hint) { + const short = hint.length > 80 ? `${hint.slice(0, 80)}...` : hint; + summary = ` \`${short}\``; + } + } + toolParts.push(`:hammer_and_wrench: *${block.name}*${summary}`); + } + } + + // Tool use takes priority — it's a distinct event kind + if (toolParts.length > 0) { + return { + kind: "tool_use", + text: toolParts.join("\n"), + }; + } + if (textParts.length > 0) { + return { + kind: "text", + text: textParts.join(""), + }; + } + return null; + } + + if (type === "user") { + const msg = toRecord(event.message); + if (!msg) { + return null; + } + const content = Array.isArray(msg.content) ? msg.content : []; + const parts: string[] = []; + + for (const rawBlock of content) { + const block = toRecord(rawBlock); + if (!block || block.type !== "tool_result") { + continue; + } + const isError = block.is_error === true; + const prefix = isError ? ":x: Error" : ":white_check_mark: Result"; + const resultText = typeof block.content === "string" ? block.content : ""; + const truncated = resultText.length > 500 ? `${resultText.slice(0, 500)}...` : resultText; + if (!truncated) { + parts.push(`${prefix}: (empty)`); + } else { + parts.push(`${prefix}:\n\`\`\`\n${truncated}\n\`\`\``); + } + } + + if (parts.length === 0) { + return null; + } + return { + kind: "tool_result", + text: parts.join("\n"), + }; + } + + return null; +} + +// #endregion + +// #region Text helpers + +export function stripMention(text: string): string { + return text.replace(/<@[A-Z0-9]+>/g, "").trim(); +} + +// #endregion + +// #region File downloads + +const DOWNLOADS_DIR = "/tmp/spa-downloads"; + +/** Download a Slack-hosted file into a thread-scoped temp dir. */ +export async function downloadSlackFile( + url: string, + filename: string, + threadTs: string, + botToken: string, +): Promise> { + try { + const resp = await fetch(url, { + headers: { + Authorization: `Bearer ${botToken}`, + }, + }); + if (!resp.ok) { + return Err(new Error(`Failed to download ${filename}: ${resp.status}`)); + } + const dir = `${DOWNLOADS_DIR}/${threadTs}`; + mkdirSync(dir, { recursive: true }); + const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); + const localPath = `${dir}/${safeName}`; + const buf = await resp.arrayBuffer(); + writeFileSync(localPath, Buffer.from(buf)); + return Ok(localPath); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return Err(new Error(`Error downloading ${filename}: ${msg}`)); + } +} + +// #endregion + +// #region Cleanup + +const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour +const CLEANUP_TIMESTAMP_PATH = `${DOWNLOADS_DIR}/.last-cleanup`; + +/** Remove download directories older than 30 days. */ +export function cleanupStaleDownloads(): void { + if (!existsSync(DOWNLOADS_DIR)) { + return; + } + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + const cutoff = Date.now() - thirtyDaysMs; + let removed = 0; + try { + for (const entry of readdirSync(DOWNLOADS_DIR)) { + const entryPath = `${DOWNLOADS_DIR}/${entry}`; + try { + const stat = statSync(entryPath); + if (stat.isDirectory() && stat.mtimeMs < cutoff) { + rmSync(entryPath, { + recursive: true, + force: true, + }); + removed++; + } + } catch { + // skip entries we can't stat + } + } + } catch { + // ignore if dir disappeared + } + if (removed > 0) { + console.log(`[spa] Cleaned up ${removed} stale download dir(s)`); + } +} + +/** Run cleanup only if at least 1 hour since last run. Persists timestamp to disk. */ +export function runCleanupIfDue(): void { + try { + if (existsSync(CLEANUP_TIMESTAMP_PATH)) { + const lastRun = Number.parseInt(readFileSync(CLEANUP_TIMESTAMP_PATH, "utf-8").trim(), 10); + if (Date.now() - lastRun < CLEANUP_INTERVAL_MS) { + return; + } + } + } catch { + // file missing or unreadable — run cleanup + } + + cleanupStaleDownloads(); + + try { + mkdirSync(DOWNLOADS_DIR, { + recursive: true, + }); + writeFileSync(CLEANUP_TIMESTAMP_PATH, String(Date.now())); + } catch { + // non-fatal + } +} + +// #endregion diff --git a/.claude/skills/setup-spa/slack-bot.ts b/.claude/skills/setup-spa/main.ts similarity index 52% rename from .claude/skills/setup-spa/slack-bot.ts rename to .claude/skills/setup-spa/main.ts index 73748a53..864819e0 100644 --- a/.claude/skills/setup-spa/slack-bot.ts +++ b/.claude/skills/setup-spa/main.ts @@ -1,8 +1,22 @@ +// SPA (Spawn's Personal Agent) — Slack bot entry point. +// Pipes Slack threads into Claude Code sessions and streams responses back. + import { App } from "@slack/bolt"; -import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync, statSync } from "node:fs"; -import { dirname } from "node:path"; import * as v from "valibot"; import { toRecord } from "@openrouter/spawn-shared"; +import { + type State, + type Mapping, + ResultSchema, + loadState, + saveState, + findMapping, + addMapping, + parseStreamEvent, + stripMention, + downloadSlackFile, + runCleanupIfDue, +} from "./helpers"; // #region Environment @@ -34,59 +48,12 @@ let BOT_USER_ID = ""; // #region State -const STATE_PATH = process.env.STATE_PATH ?? `${process.env.HOME ?? "/root"}/.config/spawn/slack-issues.json`; - -const MappingSchema = v.object({ - channel: v.string(), - threadTs: v.string(), - sessionId: v.string(), - createdAt: v.string(), -}); - -const StateSchema = v.object({ - mappings: v.array(MappingSchema), -}); - -type Mapping = v.InferOutput; -type State = v.InferOutput; - -function loadState(): State { - try { - if (!existsSync(STATE_PATH)) { - return { - mappings: [], - }; - } - const raw = readFileSync(STATE_PATH, "utf-8"); - const parsed = v.parse(StateSchema, JSON.parse(raw)); - return parsed; - } catch { - console.warn("[spa] Could not load state, starting fresh"); - return { - mappings: [], - }; - } +const stateResult = loadState(); +const state: State = stateResult.ok ? stateResult.data : { mappings: [] }; +if (!stateResult.ok) { + console.warn(`[spa] ${stateResult.error.message}, starting fresh`); } -function saveState(s: State): void { - const dir = dirname(STATE_PATH); - mkdirSync(dir, { - recursive: true, - }); - writeFileSync(STATE_PATH, `${JSON.stringify(s, null, 2)}\n`); -} - -function findMapping(s: State, channel: string, threadTs: string): Mapping | undefined { - return s.mappings.find((m) => m.channel === channel && m.threadTs === threadTs); -} - -function addMapping(s: State, mapping: Mapping): void { - s.mappings.push(mapping); - saveState(s); -} - -const state = loadState(); - // Active Claude Code processes — keyed by threadTs const activeRuns = new Map< string, @@ -100,100 +67,6 @@ const activeRuns = new Map< // #region Claude Code helpers -const ResultSchema = v.object({ - type: v.literal("result"), - session_id: v.string(), -}); - -/** - * Format a Claude Code stream-json event into Slack-friendly text, or null to skip. - * - * Claude Code emits complete messages (not Anthropic streaming deltas): - * {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}} - * {"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{...}}]}} - * {"type":"user","message":{"content":[{"type":"tool_result","content":"..."}]}} - * {"type":"result","result":"...","session_id":"..."} - */ -function formatStreamEvent(event: Record): string | null { - const type = event.type; - - // Assistant messages — contain text, tool_use, or thinking blocks - if (type === "assistant") { - const msg = toRecord(event.message); - if (!msg) { - return null; - } - const content = Array.isArray(msg.content) ? msg.content : []; - const parts: string[] = []; - - for (const rawBlock of content) { - const block = toRecord(rawBlock); - if (!block) { - continue; - } - - if (block.type === "text" && typeof block.text === "string") { - parts.push(block.text); - } - - if (block.type === "tool_use" && typeof block.name === "string") { - const input = toRecord(block.input); - // Show a short summary of the tool input - let summary = ""; - if (input) { - const cmd = typeof input.command === "string" ? input.command : null; - const pattern = typeof input.pattern === "string" ? input.pattern : null; - const filePath = typeof input.file_path === "string" ? input.file_path : null; - const hint = cmd ?? pattern ?? filePath; - if (hint) { - const short = hint.length > 80 ? `${hint.slice(0, 80)}...` : hint; - summary = ` \`${short}\``; - } - } - parts.push(`\n:hammer_and_wrench: *${block.name}*${summary}\n`); - } - } - - if (parts.length === 0) { - return null; - } - return parts.join(""); - } - - // User messages — contain tool_result blocks - if (type === "user") { - const msg = toRecord(event.message); - if (!msg) { - return null; - } - const content = Array.isArray(msg.content) ? msg.content : []; - const parts: string[] = []; - - for (const rawBlock of content) { - const block = toRecord(rawBlock); - if (!block || block.type !== "tool_result") { - continue; - } - const isError = block.is_error === true; - const prefix = isError ? ":x: Error" : ":white_check_mark: Result"; - const resultText = typeof block.content === "string" ? block.content : ""; - const truncated = resultText.length > 500 ? `${resultText.slice(0, 500)}...` : resultText; - if (!truncated) { - parts.push(`${prefix}: (empty)\n`); - } else { - parts.push(`${prefix}:\n\`\`\`\n${truncated}\n\`\`\`\n`); - } - } - - if (parts.length === 0) { - return null; - } - return parts.join(""); - } - - return null; -} - const SYSTEM_PROMPT = `You are SPA (Spawn's Personal Agent), a Slack bot for the Spawn project (${GITHUB_REPO}). Your primary job is to help manage GitHub issues based on Slack conversations: @@ -211,102 +84,6 @@ When creating issues, include a footer: "_Filed from Slack by SPA_" Below is the full Slack thread. The most recent message is the one you should respond to. Prior messages are context.`; -const DOWNLOADS_DIR = "/tmp/spa-downloads"; - -/** Download a Slack-hosted file into a thread-scoped temp dir. Returns the local path or null. */ -async function downloadSlackFile(url: string, filename: string, threadTs: string): Promise { - try { - const resp = await fetch(url, { - headers: { - Authorization: `Bearer ${SLACK_BOT_TOKEN}`, - }, - }); - if (!resp.ok) { - console.error(`[spa] Failed to download ${filename}: ${resp.status}`); - return null; - } - const dir = `${DOWNLOADS_DIR}/${threadTs}`; - mkdirSync(dir, { - recursive: true, - }); - const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); - const localPath = `${dir}/${safeName}`; - const buf = await resp.arrayBuffer(); - writeFileSync(localPath, Buffer.from(buf)); - return localPath; - } catch (err) { - console.error(`[spa] Error downloading ${filename}:`, err); - return null; - } -} - -/** Remove download directories older than 30 days. */ -function cleanupStaleDownloads(): void { - if (!existsSync(DOWNLOADS_DIR)) { - return; - } - const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; - const cutoff = Date.now() - thirtyDaysMs; - let removed = 0; - try { - for (const entry of readdirSync(DOWNLOADS_DIR)) { - const entryPath = `${DOWNLOADS_DIR}/${entry}`; - try { - const stat = statSync(entryPath); - if (stat.isDirectory() && stat.mtimeMs < cutoff) { - rmSync(entryPath, { - recursive: true, - force: true, - }); - removed++; - } - } catch { - // skip entries we can't stat - } - } - } catch { - // ignore if dir disappeared - } - if (removed > 0) { - console.log(`[spa] Cleaned up ${removed} stale download dir(s)`); - } -} - -const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour -const CLEANUP_TIMESTAMP_PATH = `${DOWNLOADS_DIR}/.last-cleanup`; -let cleanupTimer: ReturnType | null = null; - -/** Run cleanup only if at least 1 hour since last run. Persists timestamp to disk. */ -function runCleanupIfDue(): void { - try { - if (existsSync(CLEANUP_TIMESTAMP_PATH)) { - const lastRun = Number.parseInt(readFileSync(CLEANUP_TIMESTAMP_PATH, "utf-8").trim(), 10); - if (Date.now() - lastRun < CLEANUP_INTERVAL_MS) { - return; - } - } - } catch { - // file missing or unreadable — run cleanup - } - - cleanupStaleDownloads(); - - try { - mkdirSync(DOWNLOADS_DIR, { - recursive: true, - }); - writeFileSync(CLEANUP_TIMESTAMP_PATH, String(Date.now())); - } catch { - // non-fatal - } -} - -/** Start the hourly cleanup schedule. */ -function startCleanupSchedule(): void { - runCleanupIfDue(); - cleanupTimer = setInterval(runCleanupIfDue, CLEANUP_INTERVAL_MS); -} - /** * Fetch full thread history from Slack and format as a prompt. */ @@ -354,9 +131,11 @@ async function buildThreadPrompt( if (!url) { continue; } - const localPath = await downloadSlackFile(url, name, threadTs); - if (localPath) { - parts.push(`[File: ${name}] → ${localPath}`); + const dlResult = await downloadSlackFile(url, name, threadTs, SLACK_BOT_TOKEN); + if (dlResult.ok) { + parts.push(`[File: ${name}] → ${dlResult.data}`); + } else { + console.error(`[spa] ${dlResult.error.message}`); } } } @@ -413,16 +192,22 @@ async function runClaudeAndStream( args.push("--resume", sessionId); } - args.push(prompt); + // Pass prompt via stdin to avoid CLI flag parsing issues with user content + args.push("-"); console.log(`[spa] Starting claude session (thread=${threadTs}, resume=${sessionId ?? "new"})`); const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe", + stdin: "pipe", cwd: process.env.REPO_ROOT ?? process.cwd(), }); + // Write prompt to stdin and close + proc.stdin.write(prompt); + proc.stdin.end(); + activeRuns.set(threadTs, { proc, startedAt: Date.now(), @@ -437,15 +222,62 @@ async function runClaudeAndStream( }) .catch(() => null); - const updateTs = thinkingMsg?.ts; - let fullText = ""; - let lastUpdateLen = 0; + let currentMsgTs = thinkingMsg?.ts; + let currentText = ""; let returnedSessionId: string | null = null; + let hasOutput = false; // Throttle Slack updates — update at most every 2s let lastUpdateTime = 0; const UPDATE_INTERVAL_MS = 2000; - const MAX_MSG_LEN = 3900; // Slack limit ~4000, leave room + const MAX_MSG_LEN = 3800; // Slack limit ~4000, leave room for formatting + + /** Update the current Slack message, or post a new one if at limit. */ + async function flushToSlack(text: string, forceNew = false): Promise { + if (!text) { + return; + } + hasOutput = true; + + // Need a new message if text would exceed limit or forced + if (forceNew || !currentMsgTs || text.length > MAX_MSG_LEN) { + // If there's leftover text in the current message, finalize it first + if (currentMsgTs && currentText) { + await client.chat + .update({ + channel, + ts: currentMsgTs, + text: currentText.slice(0, MAX_MSG_LEN), + }) + .catch(() => {}); + } + + // Post a new message + const newMsg = await client.chat + .postMessage({ + channel, + thread_ts: threadTs, + text: text.slice(0, MAX_MSG_LEN), + }) + .catch(() => null); + + currentMsgTs = newMsg?.ts; + currentText = text.slice(0, MAX_MSG_LEN); + return; + } + + await client.chat + .update({ + channel, + ts: currentMsgTs, + text: text.slice(0, MAX_MSG_LEN), + }) + .catch(() => {}); + currentText = text.slice(0, MAX_MSG_LEN); + } + + // Accumulates text for the current "section" (consecutive text blocks) + let pendingText = ""; const decoder = new TextDecoder(); const reader = proc.stdout.getReader(); @@ -488,27 +320,28 @@ async function runClaudeAndStream( returnedSessionId = resultEvent.output.session_id; } - // Format event for Slack display - const segment = formatStreamEvent(obj); - if (segment) { - fullText += segment; + // Parse event into typed segment + const segment = parseStreamEvent(obj); + if (!segment) { + continue; + } + + if (segment.kind === "text") { + pendingText += segment.text; + } else { + // tool_use and tool_result get their own messages + if (pendingText) { + await flushToSlack(pendingText); + pendingText = ""; + } + await flushToSlack(segment.text, true); } } - // Throttled Slack update + // Throttled update for accumulated text const now = Date.now(); - if (updateTs && fullText.length > lastUpdateLen && now - lastUpdateTime >= UPDATE_INTERVAL_MS) { - const displayText = fullText.length > MAX_MSG_LEN ? `...${fullText.slice(-MAX_MSG_LEN)}` : fullText; - - await client.chat - .update({ - channel, - ts: updateTs, - text: displayText, - }) - .catch(() => {}); - - lastUpdateLen = fullText.length; + if (pendingText && now - lastUpdateTime >= UPDATE_INTERVAL_MS) { + await flushToSlack(pendingText); lastUpdateTime = now; } } @@ -516,17 +349,22 @@ async function runClaudeAndStream( activeRuns.delete(threadTs); } + // Flush any remaining text + if (pendingText) { + await flushToSlack(pendingText); + } + // Read stderr for errors const stderr = await new Response(proc.stderr).text(); const exitCode = await proc.exited; - if (exitCode !== 0 && !fullText) { + if (exitCode !== 0 && !hasOutput) { console.error(`[spa] claude exited ${exitCode}: ${stderr}`); - if (updateTs) { + if (currentMsgTs) { await client.chat .update({ channel, - ts: updateTs, + ts: currentMsgTs, text: `:x: Claude Code errored (exit ${exitCode}):\n\`\`\`\n${stderr.slice(0, 1500)}\n\`\`\``, }) .catch(() => {}); @@ -534,44 +372,23 @@ async function runClaudeAndStream( return null; } - // Final update with complete text - if (updateTs && fullText) { - const displayText = fullText.length > MAX_MSG_LEN ? `...${fullText.slice(-MAX_MSG_LEN)}` : fullText; - + if (!hasOutput && currentMsgTs) { await client.chat .update({ channel, - ts: updateTs, - text: displayText, - }) - .catch(() => {}); - } - - if (!fullText && updateTs) { - await client.chat - .update({ - channel, - ts: updateTs, + ts: currentMsgTs, text: ":white_check_mark: Done (no text output)", }) .catch(() => {}); } - console.log(`[spa] Claude done (thread=${threadTs}, session=${returnedSessionId}, len=${fullText.length})`); + console.log(`[spa] Claude done (thread=${threadTs}, session=${returnedSessionId})`); return returnedSessionId; } // #endregion -// #region Text helpers - -function stripMention(text: string): string { - return text.replace(/<@[A-Z0-9]+>/g, "").trim(); -} - -// #endregion - // #region Core handler async function handleThread( @@ -610,19 +427,21 @@ async function handleThread( .catch(() => {}); // Run Claude Code and stream back - const sessionId = await runClaudeAndStream(client, channel, threadTs, prompt, existing?.sessionId); + const newSessionId = await runClaudeAndStream(client, channel, threadTs, prompt, existing?.sessionId); // Save session mapping - if (sessionId && !existing) { - addMapping(state, { + if (newSessionId && !existing) { + const r = addMapping(state, { channel, threadTs, - sessionId, + sessionId: newSessionId, createdAt: new Date().toISOString(), }); - } else if (sessionId && existing) { - existing.sessionId = sessionId; - saveState(state); + if (!r.ok) console.error(`[spa] ${r.error.message}`); + } else if (newSessionId && existing) { + existing.sessionId = newSessionId; + const r = saveState(state); + if (!r.ok) console.error(`[spa] ${r.error.message}`); } } @@ -653,16 +472,13 @@ app.event("app_mention", async ({ event, client }) => { function shutdown(signal: string): void { console.log(`[spa] Received ${signal}, shutting down...`); - if (cleanupTimer) { - clearInterval(cleanupTimer); - } - for (const [threadTs, run] of activeRuns) { console.log(`[spa] Killing active run for thread ${threadTs}`); run.proc.kill("SIGTERM"); } - saveState(state); + const r = saveState(state); + if (!r.ok) console.error(`[spa] ${r.error.message}`); process.exit(0); } @@ -674,7 +490,7 @@ process.on("SIGINT", () => shutdown("SIGINT")); // #region Start (async () => { - startCleanupSchedule(); + runCleanupIfDue(); // Resolve our own bot user ID const authResult = await app.client.auth.test({ diff --git a/.claude/skills/setup-spa/package.json b/.claude/skills/setup-spa/package.json index 1713d0a6..a8e9be52 100644 --- a/.claude/skills/setup-spa/package.json +++ b/.claude/skills/setup-spa/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "start": "bun run slack-bot.ts" + "start": "bun run main.ts" }, "dependencies": { "@openrouter/spawn-shared": "workspace:*", diff --git a/.claude/skills/setup-spa/spa.test.ts b/.claude/skills/setup-spa/spa.test.ts new file mode 100644 index 00000000..e5229aa2 --- /dev/null +++ b/.claude/skills/setup-spa/spa.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"; +import { parseStreamEvent, stripMention, loadState, saveState, downloadSlackFile } from "./helpers"; +import { toRecord } from "@openrouter/spawn-shared"; +import streamEvents from "../../../fixtures/claude-code/stream-events.json"; + +// Helper: extract a fixture event by index and cast to Record +function fixture(index: number): Record { + const event = toRecord(streamEvents[index]); + if (!event) throw new Error(`Fixture at index ${index} is not a record`); + return event; +} + +describe("parseStreamEvent", () => { + it("parses assistant text from fixture", () => { + // fixture[0]: assistant with text "I'll look at the issue..." + const result = parseStreamEvent(fixture(0)); + expect(result).toEqual({ + kind: "text", + text: "I'll look at the issue and check the repository structure.", + }); + }); + + it("parses assistant tool_use (Bash) from fixture", () => { + // fixture[1]: assistant with tool_use Bash + const result = parseStreamEvent(fixture(1)); + expect(result?.kind).toBe("tool_use"); + expect(result?.text).toContain(":hammer_and_wrench: *Bash*"); + expect(result?.text).toContain("gh issue list"); + }); + + it("parses user tool_result (success) from fixture", () => { + // fixture[2]: user with successful tool_result + const result = parseStreamEvent(fixture(2)); + expect(result?.kind).toBe("tool_result"); + expect(result?.text).toContain(":white_check_mark: Result"); + expect(result?.text).toContain("Fly.io deploy fails on arm64"); + }); + + it("parses assistant tool_use (Glob) from fixture", () => { + // fixture[3]: assistant with tool_use Glob + const result = parseStreamEvent(fixture(3)); + expect(result).toEqual({ + kind: "tool_use", + text: ":hammer_and_wrench: *Glob* `**/*.ts`", + }); + }); + + it("parses assistant tool_use (Read) from fixture", () => { + // fixture[5]: assistant with tool_use Read + const result = parseStreamEvent(fixture(5)); + expect(result?.kind).toBe("tool_use"); + expect(result?.text).toContain(":hammer_and_wrench: *Read*"); + expect(result?.text).toContain("index.ts"); + }); + + it("parses user tool_result (error) from fixture", () => { + // fixture[6]: user with is_error: true + const result = parseStreamEvent(fixture(6)); + expect(result?.kind).toBe("tool_result"); + expect(result?.text).toContain(":x: Error"); + expect(result?.text).toContain("Permission denied"); + }); + + it("parses final assistant text from fixture", () => { + // fixture[7]: assistant with summary text + const result = parseStreamEvent(fixture(7)); + expect(result?.kind).toBe("text"); + expect(result?.text).toContain("#1234"); + expect(result?.text).toContain("Would you like me to create a new issue"); + }); + + it("returns null for result event (not assistant/user)", () => { + // fixture[8]: result event with session_id + const result = parseStreamEvent(fixture(8)); + expect(result).toBeNull(); + }); + + it("truncates long tool hints to 80 chars", () => { + const longCmd = "a".repeat(100); + const event: Record = { + type: "assistant", + message: { + content: [{ type: "tool_use", name: "Bash", input: { command: longCmd } }], + }, + }; + const result = parseStreamEvent(event); + expect(result?.text).toContain("..."); + expect(result?.kind).toBe("tool_use"); + }); + + it("returns null for empty assistant content", () => { + const event: Record = { + type: "assistant", + message: { content: [] }, + }; + expect(parseStreamEvent(event)).toBeNull(); + }); + + it("returns null for unknown event types", () => { + expect(parseStreamEvent({ type: "unknown" })).toBeNull(); + }); + + it("returns null for assistant without message", () => { + expect(parseStreamEvent({ type: "assistant" })).toBeNull(); + }); + + it("returns null for user without tool_result blocks", () => { + const event: Record = { + type: "user", + message: { + content: [{ type: "text", text: "not a tool result" }], + }, + }; + expect(parseStreamEvent(event)).toBeNull(); + }); + + it("handles tool_use without input gracefully", () => { + const event: Record = { + type: "assistant", + message: { + content: [{ type: "tool_use", name: "Bash" }], + }, + }; + const result = parseStreamEvent(event); + expect(result).toEqual({ kind: "tool_use", text: ":hammer_and_wrench: *Bash*" }); + }); + + it("prefers tool_use over text when both present", () => { + const event: Record = { + type: "assistant", + message: { + content: [ + { type: "text", text: "some text" }, + { type: "tool_use", name: "Bash", input: { command: "echo hi" } }, + ], + }, + }; + const result = parseStreamEvent(event); + expect(result?.kind).toBe("tool_use"); + }); + + it("handles empty tool_result content", () => { + const event: Record = { + type: "user", + message: { + content: [{ type: "tool_result", content: "" }], + }, + }; + const result = parseStreamEvent(event); + expect(result?.text).toContain("(empty)"); + }); + + it("truncates long tool results to 500 chars", () => { + const longResult = "x".repeat(600); + const event: Record = { + type: "user", + message: { + content: [{ type: "tool_result", content: longResult }], + }, + }; + const result = parseStreamEvent(event); + expect(result?.text).toContain("..."); + }); +}); + +describe("stripMention", () => { + it("strips a single mention", () => { + expect(stripMention("<@U12345> hello")).toBe("hello"); + }); + + it("strips multiple mentions", () => { + expect(stripMention("<@U12345> <@U67890> hello")).toBe("hello"); + }); + + it("returns text without mentions unchanged", () => { + expect(stripMention("hello world")).toBe("hello world"); + }); + + it("trims whitespace", () => { + expect(stripMention(" <@U12345> ")).toBe(""); + }); +}); + +describe("loadState", () => { + it("returns a Result object", () => { + // STATE_PATH is captured at module load time; the default path likely + // doesn't exist in CI, so loadState returns Ok({ mappings: [] }) + const result = loadState(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.mappings).toBeInstanceOf(Array); + } + }); +}); + +describe("saveState", () => { + it("returns a Result object", () => { + // Write to a temp file by using the module's STATE_PATH (default). + // If the default dir is writable, we get Ok; if not, Err. Either way it's a Result. + const result = saveState({ mappings: [] }); + expect(typeof result.ok).toBe("boolean"); + }); +}); + +describe("downloadSlackFile", () => { + afterEach(() => { + mock.restore(); + }); + + it("returns Ok with local path on success", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response("file-content", { status: 200 })), + ); + + try { + const threadTs = `test-${Date.now()}`; + const result = await downloadSlackFile( + "https://files.slack.com/test.txt", + "test.txt", + threadTs, + "xoxb-fake-token", + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toContain("test.txt"); + expect(result.data).toContain(threadTs); + } + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("returns Err on HTTP error", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response("Not Found", { status: 404 })), + ); + + try { + const result = await downloadSlackFile( + "https://files.slack.com/missing.txt", + "missing.txt", + "thread-123", + "xoxb-fake-token", + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("404"); + } + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("returns Err on network failure", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => Promise.reject(new Error("Network failure"))); + + try { + const result = await downloadSlackFile( + "https://files.slack.com/fail.txt", + "fail.txt", + "thread-456", + "xoxb-fake-token", + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("Network failure"); + } + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/fixtures/claude-code/_metadata.json b/fixtures/claude-code/_metadata.json new file mode 100644 index 00000000..ff0abbe3 --- /dev/null +++ b/fixtures/claude-code/_metadata.json @@ -0,0 +1,7 @@ +{ + "agent": "claude-code", + "recorded_at": "2026-02-24T00:00:00Z", + "fixtures": { + "stream-events": {"format": "stream-json", "type": "synthetic", "recorded_at": "2026-02-24T00:00:00Z"} + } +} diff --git a/fixtures/claude-code/stream-events.json b/fixtures/claude-code/stream-events.json new file mode 100644 index 00000000..9dbb1561 --- /dev/null +++ b/fixtures/claude-code/stream-events.json @@ -0,0 +1,107 @@ +[ + { + "type": "assistant", + "message": { + "id": "msg_01ABC", + "type": "message", + "role": "assistant", + "content": [ + {"type": "text", "text": "I'll look at the issue and check the repository structure."} + ], + "model": "claude-sonnet-4-20250514", + "stop_reason": "end_turn" + } + }, + { + "type": "assistant", + "message": { + "id": "msg_01DEF", + "type": "message", + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "toolu_01ABC", "name": "Bash", "input": {"command": "gh issue list --repo OpenRouterTeam/spawn --state open --limit 5"}} + ], + "model": "claude-sonnet-4-20250514", + "stop_reason": "tool_use" + } + }, + { + "type": "user", + "message": { + "id": "msg_user_01", + "type": "message", + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "toolu_01ABC", "content": "#1234 [Bug]: Fly.io deploy fails on arm64\n#1235 [CLI]: Add --json flag to list command", "is_error": false} + ] + } + }, + { + "type": "assistant", + "message": { + "id": "msg_01GHI", + "type": "message", + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "toolu_01DEF", "name": "Glob", "input": {"pattern": "**/*.ts"}} + ], + "model": "claude-sonnet-4-20250514", + "stop_reason": "tool_use" + } + }, + { + "type": "user", + "message": { + "id": "msg_user_02", + "type": "message", + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "toolu_01DEF", "content": "packages/cli/src/index.ts\npackages/cli/src/commands.ts\npackages/cli/src/manifest.ts", "is_error": false} + ] + } + }, + { + "type": "assistant", + "message": { + "id": "msg_01JKL", + "type": "message", + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "toolu_01GHI", "name": "Read", "input": {"file_path": "/home/user/spawn/packages/cli/src/index.ts"}} + ], + "model": "claude-sonnet-4-20250514", + "stop_reason": "tool_use" + } + }, + { + "type": "user", + "message": { + "id": "msg_user_03", + "type": "message", + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "toolu_01GHI", "content": "Command execution failed: Permission denied", "is_error": true} + ] + } + }, + { + "type": "assistant", + "message": { + "id": "msg_01MNO", + "type": "message", + "role": "assistant", + "content": [ + {"type": "text", "text": "I've reviewed the open issues. Here's a summary:\n\n1. **#1234** - Fly.io deploy fails on arm64 architecture\n2. **#1235** - Request to add `--json` flag to the list command\n\nWould you like me to create a new issue or work on one of these?"} + ], + "model": "claude-sonnet-4-20250514", + "stop_reason": "end_turn" + } + }, + { + "type": "result", + "subtype": "success", + "session_id": "abc123-def456-ghi789", + "is_error": false, + "result": "I've reviewed the open issues and provided a summary." + } +] diff --git a/packages/cli/package.json b/packages/cli/package.json index 5fa7412f..5b436df6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.10.0", + "version": "0.10.1", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/aws.test.ts b/packages/cli/src/__tests__/aws.test.ts index d54075a1..7fa39ed4 100644 --- a/packages/cli/src/__tests__/aws.test.ts +++ b/packages/cli/src/__tests__/aws.test.ts @@ -29,25 +29,42 @@ describe("aws/credential-cache", () => { describe("loadCredsFromConfig", () => { it("returns null when config file does not exist", () => { - if (existsSync(AWS_CONFIG_PATH)) { unlinkSync(AWS_CONFIG_PATH); } + if (existsSync(AWS_CONFIG_PATH)) { + unlinkSync(AWS_CONFIG_PATH); + } expect(loadCredsFromConfig()).toBeNull(); }); it("returns null for malformed JSON", async () => { - await Bun.write(AWS_CONFIG_PATH, "not-json", { mode: 0o600 }); + await Bun.write(AWS_CONFIG_PATH, "not-json", { + mode: 0o600, + }); expect(loadCredsFromConfig()).toBeNull(); }); it("returns null when accessKeyId is missing", async () => { - await Bun.write(AWS_CONFIG_PATH, JSON.stringify({ secretAccessKey: "secretsecretkey1234" }), { mode: 0o600 }); + await Bun.write( + AWS_CONFIG_PATH, + JSON.stringify({ + secretAccessKey: "secretsecretkey1234", + }), + { + mode: 0o600, + }, + ); expect(loadCredsFromConfig()).toBeNull(); }); it("returns null when secretAccessKey is too short", async () => { await Bun.write( AWS_CONFIG_PATH, - JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "tooshort" }), - { mode: 0o600 }, + JSON.stringify({ + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "tooshort", + }), + { + mode: 0o600, + }, ); expect(loadCredsFromConfig()).toBeNull(); }); @@ -55,8 +72,13 @@ describe("aws/credential-cache", () => { it("returns null for invalid accessKeyId format", async () => { await Bun.write( AWS_CONFIG_PATH, - JSON.stringify({ accessKeyId: "invalid key!", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY" }), - { mode: 0o600 }, + JSON.stringify({ + accessKeyId: "invalid key!", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", + }), + { + mode: 0o600, + }, ); expect(loadCredsFromConfig()).toBeNull(); }); @@ -64,8 +86,14 @@ describe("aws/credential-cache", () => { it("returns credentials for valid data", async () => { await Bun.write( AWS_CONFIG_PATH, - JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", region: "eu-west-1" }), - { mode: 0o600 }, + JSON.stringify({ + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", + region: "eu-west-1", + }), + { + mode: 0o600, + }, ); const result = loadCredsFromConfig(); expect(result).not.toBeNull(); @@ -77,8 +105,13 @@ describe("aws/credential-cache", () => { it("defaults region to us-east-1 when not stored", async () => { await Bun.write( AWS_CONFIG_PATH, - JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY" }), - { mode: 0o600 }, + JSON.stringify({ + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", + }), + { + mode: 0o600, + }, ); const result = loadCredsFromConfig(); expect(result?.region).toBe("us-east-1"); @@ -87,7 +120,9 @@ describe("aws/credential-cache", () => { describe("saveCredsToConfig", () => { it("writes credentials to config file", async () => { - if (existsSync(AWS_CONFIG_PATH)) { unlinkSync(AWS_CONFIG_PATH); } + if (existsSync(AWS_CONFIG_PATH)) { + unlinkSync(AWS_CONFIG_PATH); + } await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", "us-west-2"); const result = loadCredsFromConfig(); expect(result?.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE"); @@ -96,7 +131,9 @@ describe("aws/credential-cache", () => { }); it("round-trips credentials with special characters in secret key", async () => { - if (existsSync(AWS_CONFIG_PATH)) { unlinkSync(AWS_CONFIG_PATH); } + if (existsSync(AWS_CONFIG_PATH)) { + unlinkSync(AWS_CONFIG_PATH); + } const secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCY=="; await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE", secret, "ap-northeast-1"); const result = loadCredsFromConfig(); diff --git a/packages/cli/src/aws/aws.ts b/packages/cli/src/aws/aws.ts index b8dc6cde..93b4d04c 100644 --- a/packages/cli/src/aws/aws.ts +++ b/packages/cli/src/aws/aws.ts @@ -39,18 +39,34 @@ const AwsCredsSchema = v.object({ export async function saveCredsToConfig(accessKeyId: string, secretAccessKey: string, region: string): Promise { const dir = AWS_CONFIG_PATH.replace(/\/[^/]+$/, ""); - await Bun.spawn(["mkdir", "-p", dir]).exited; + await Bun.spawn([ + "mkdir", + "-p", + dir, + ]).exited; const payload = `{\n "accessKeyId": ${jsonEscape(accessKeyId)},\n "secretAccessKey": ${jsonEscape(secretAccessKey)},\n "region": ${jsonEscape(region)}\n}\n`; - await Bun.write(AWS_CONFIG_PATH, payload, { mode: 0o600 }); + await Bun.write(AWS_CONFIG_PATH, payload, { + mode: 0o600, + }); } -export function loadCredsFromConfig(): { accessKeyId: string; secretAccessKey: string; region: string } | null { +export function loadCredsFromConfig(): { + accessKeyId: string; + secretAccessKey: string; + region: string; +} | null { try { const raw = readFileSync(AWS_CONFIG_PATH, "utf-8"); const data = parseJsonWith(raw, AwsCredsSchema); - if (!data?.accessKeyId || !data?.secretAccessKey) { return null; } - if (!/^[A-Za-z0-9/+]{16,128}$/.test(data.accessKeyId)) { return null; } - if (data.secretAccessKey.length < 16) { return null; } + if (!data?.accessKeyId || !data?.secretAccessKey) { + return null; + } + if (!/^[A-Za-z0-9/+]{16,128}$/.test(data.accessKeyId)) { + return null; + } + if (data.secretAccessKey.length < 16) { + return null; + } return { accessKeyId: data.accessKeyId, secretAccessKey: data.secretAccessKey, @@ -521,7 +537,10 @@ export async function authenticate(): Promise { awsSecretAccessKey = cached.secretAccessKey; if (hasAwsCli()) { - const result = awsCliSync(["sts", "get-caller-identity"]); + const result = awsCliSync([ + "sts", + "get-caller-identity", + ]); if (result.exitCode === 0) { lightsailMode = "cli"; logInfo(`AWS CLI ready with cached credentials, using region: ${cachedRegion}`); diff --git a/packages/cli/src/daytona/daytona.ts b/packages/cli/src/daytona/daytona.ts index 7037e541..bdbf27e4 100644 --- a/packages/cli/src/daytona/daytona.ts +++ b/packages/cli/src/daytona/daytona.ts @@ -55,7 +55,6 @@ function parseJson(text: string): Record | null { return parseJsonWith(text, LooseObject); } - async function daytonaApi(method: string, endpoint: string, body?: string, maxRetries = 3): Promise { const url = `${DAYTONA_API_BASE}${endpoint}`; diff --git a/packages/cli/src/shared/ui.ts b/packages/cli/src/shared/ui.ts index 9e96e1be..7b6b900f 100644 --- a/packages/cli/src/shared/ui.ts +++ b/packages/cli/src/shared/ui.ts @@ -171,8 +171,8 @@ export function openBrowser(url: string): void { // ─── Result-based retry ──────────────────────────────────────────────── -import type { Result } from "./result"; -export { type Result, Ok, Err } from "./result"; +import type { Result } from "@openrouter/spawn-shared"; +export { type Result, Ok, Err } from "@openrouter/spawn-shared"; /** * Phase-aware retry helper using the Result monad. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 92939fc1..9b79c04b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ export { isString, isNumber, hasStatus, hasMessage, toRecord, toObjectArray } from "./type-guards"; export { parseJsonWith, parseJsonRaw } from "./parse"; +export { type Result, Ok, Err } from "./result"; diff --git a/packages/cli/src/shared/result.ts b/packages/shared/src/result.ts similarity index 100% rename from packages/cli/src/shared/result.ts rename to packages/shared/src/result.ts