From 4bdb9dc18c8f5df2551c221b88ba9120eb26f5c5 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 08:20:22 -0500 Subject: [PATCH 01/24] =?UTF-8?q?fix:=20calendar=20get=5Fevents=20shows=20?= =?UTF-8?q?event=20IDs=20only=20=E2=80=94=20add=20summary=20+=20start=20ti?= =?UTF-8?q?me=20(#58778)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/platform-run.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-run.ts b/packages/opencode/src/cli/cmd/platform-run.ts index 7304c0b13549..fec2f019560e 100644 --- a/packages/opencode/src/cli/cmd/platform-run.ts +++ b/packages/opencode/src/cli/cmd/platform-run.ts @@ -313,10 +313,13 @@ export async function executeIntegrationCall( function displayArrayItems(items: any[], indent = " "): void { for (const item of items.slice(0, 25)) { if (typeof item === "object" && item !== null) { - const label = item.name ?? item.title ?? item.subject ?? item.filename ?? item.email ?? item.id ?? "" - const id = item.id && label !== item.id ? dim(` #${item.id}`) : "" + const label = item.name ?? item.title ?? item.summary ?? item.subject ?? item.filename ?? item.email ?? item.id ?? "" + const id = item.id && label !== item.id ? dim(` #${String(item.id).slice(0, 12)}`) : "" const type = item.mimeType ?? item.type ?? "" - console.log(`${indent}${bold(String(label))}${id}${type ? ` ${dim(type)}` : ""}`) + // Calendar events: show start/end time + const startTime = item.start?.dateTime ?? item.start?.date ?? item.start_time ?? "" + const timeLabel = startTime ? dim(` ${startTime}`) : "" + console.log(`${indent}${bold(String(label))}${id}${timeLabel}${type ? ` ${dim(type)}` : ""}`) } else { console.log(`${indent}${item}`) } From f485bc53e7d68ca2e57eba41bf0867b1dbb71e7d Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 08:27:40 -0500 Subject: [PATCH 02/24] =?UTF-8?q?fix:=20calendar=20times=20=E2=80=94=20sta?= =?UTF-8?q?rt=20can=20be=20string=20or=20object=20(#58779)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/platform-run.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-run.ts b/packages/opencode/src/cli/cmd/platform-run.ts index fec2f019560e..10f05b0bbfd3 100644 --- a/packages/opencode/src/cli/cmd/platform-run.ts +++ b/packages/opencode/src/cli/cmd/platform-run.ts @@ -317,7 +317,9 @@ function displayArrayItems(items: any[], indent = " "): void { const id = item.id && label !== item.id ? dim(` #${String(item.id).slice(0, 12)}`) : "" const type = item.mimeType ?? item.type ?? "" // Calendar events: show start/end time - const startTime = item.start?.dateTime ?? item.start?.date ?? item.start_time ?? "" + // start may be a string ("2026-04-18T09:00:00") or object ({ dateTime: "..." }) + const rawStart = item.start + const startTime = typeof rawStart === "string" ? rawStart : (rawStart?.dateTime ?? rawStart?.date ?? item.start_time ?? "") const timeLabel = startTime ? dim(` ${startTime}`) : "" console.log(`${indent}${bold(String(label))}${id}${timeLabel}${type ? ` ${dim(type)}` : ""}`) } else { From df50c1155ec29a66d19757985ac5e736ac4871e6 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 08:31:01 -0500 Subject: [PATCH 03/24] =?UTF-8?q?feat:=20iris=20obs=20=E2=80=94=20control?= =?UTF-8?q?=20OBS=20Studio=20via=20WebSocket=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New platform-obs.ts with 10 subcommands: - iris obs connect [ws://localhost:4455] — connect to OBS - iris obs disconnect — disconnect - iris obs scenes — list scenes (shows current) - iris obs scene — switch scene - iris obs stream start|stop|status — streaming control - iris obs record start|stop|status — recording control - iris obs marker "description" — create timestamp marker - iris obs mute — toggle audio mute - iris obs inputs — list audio/video sources - iris obs status — full connection + stream + recording status Bridge: OBS channel driver (channels/obs.js) using obs-websocket-js. 15 REST endpoints on bridge at /api/obs/*. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/command-groups.ts | 1 + packages/opencode/src/cli/cmd/platform-obs.ts | 316 ++++++++++++++++++ packages/opencode/src/index.ts | 2 + 3 files changed, 319 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/platform-obs.ts diff --git a/packages/opencode/src/cli/cmd/command-groups.ts b/packages/opencode/src/cli/cmd/command-groups.ts index 08ee528208f9..9347f1267408 100644 --- a/packages/opencode/src/cli/cmd/command-groups.ts +++ b/packages/opencode/src/cli/cmd/command-groups.ts @@ -138,6 +138,7 @@ export const COMMAND_CATEGORY_MAP: Record = { profile: "entities", // Communication + obs: "communication", phone: "communication", voice: "communication", transcribe: "communication", diff --git a/packages/opencode/src/cli/cmd/platform-obs.ts b/packages/opencode/src/cli/cmd/platform-obs.ts new file mode 100644 index 000000000000..8cd298d3a42e --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-obs.ts @@ -0,0 +1,316 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { dim, bold, success, highlight } from "./iris-api" + +// ============================================================================ +// OBS Studio CLI — control OBS via WebSocket through the IRIS bridge +// +// Bridge endpoints: /api/obs/* on localhost:3200 +// OBS WebSocket: obs-websocket v5 on localhost:4455 +// ============================================================================ + +const BRIDGE = "http://localhost:3200" + +async function obsFetch(path: string, method = "GET", body?: any): Promise { + const res = await fetch(`${BRIDGE}${path}`, { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(10000), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) + throw new Error(err.error || `HTTP ${res.status}`) + } + return res.json() +} + +function printDivider() { console.log(dim(" " + "─".repeat(60))) } + +// ── connect ── + +const ConnectCmd = cmd({ + command: "connect [url]", + describe: "connect to OBS WebSocket (default: ws://localhost:4455)", + builder: (y) => + y + .positional("url", { type: "string", default: "ws://localhost:4455" }) + .option("password", { type: "string", describe: "OBS WebSocket password" }), + async handler(args) { + UI.empty() + prompts.intro("◈ OBS Connect") + const sp = prompts.spinner() + sp.start(`Connecting to ${args.url}…`) + try { + const result = await obsFetch("/api/providers/obs", "POST", { + ws_url: args.url, + password: args.password ?? undefined, + }) + sp.stop(success(`Connected to ${result.host}`)) + } catch (e: any) { + sp.stop("Failed") + prompts.log.error(e.message) + prompts.log.info(dim("Make sure OBS is running with WebSocket Server enabled")) + prompts.log.info(dim("OBS → Tools → WebSocket Server Settings → Enable")) + } + prompts.outro("Done") + }, +}) + +// ── disconnect ── + +const DisconnectCmd = cmd({ + command: "disconnect", + describe: "disconnect from OBS", + async handler() { + UI.empty() + prompts.intro("◈ OBS Disconnect") + try { + await obsFetch("/api/providers/obs", "DELETE") + prompts.log.success("Disconnected from OBS") + } catch (e: any) { + prompts.log.error(e.message) + } + prompts.outro("Done") + }, +}) + +// ── scenes ── + +const ScenesCmd = cmd({ + command: "scenes", + aliases: ["ls"], + describe: "list available OBS scenes", + async handler() { + UI.empty() + prompts.intro("◈ OBS Scenes") + try { + const data = await obsFetch("/api/obs/scenes") + printDivider() + for (const s of data.scenes || []) { + const current = s.name === data.current ? success(" ● LIVE") : "" + console.log(` ${highlight(s.name)}${current}`) + } + printDivider() + if (data.current) { + console.log(` ${dim("Current:")} ${bold(data.current)}`) + } + } catch (e: any) { + prompts.log.error(e.message) + } + prompts.outro("Done") + }, +}) + +// ── scene ── + +const SceneCmd = cmd({ + command: "scene ", + aliases: ["switch"], + describe: "switch to a scene", + builder: (y) => y.positional("name", { type: "string", demandOption: true }), + async handler(args) { + UI.empty() + const sp = prompts.spinner() + sp.start(`Switching to "${args.name}"…`) + try { + await obsFetch("/api/obs/scene", "POST", { scene_name: args.name }) + sp.stop(success(`Scene: ${args.name}`)) + } catch (e: any) { + sp.stop("Failed") + prompts.log.error(e.message) + } + }, +}) + +// ── stream start|stop|status ── + +const StreamCmd = cmd({ + command: "stream ", + describe: "control streaming (start|stop|status)", + builder: (y) => y.positional("action", { type: "string", choices: ["start", "stop", "status"], demandOption: true }), + async handler(args) { + UI.empty() + if (args.action === "status") { + try { + const data = await obsFetch("/api/obs/stream/status") + prompts.intro("◈ Stream Status") + console.log(` ${bold("Active:")} ${data.active ? success("LIVE") : dim("offline")}`) + if (data.timecode) console.log(` ${bold("Timecode:")} ${data.timecode}`) + if (data.bytes) console.log(` ${bold("Sent:")} ${(data.bytes / 1024 / 1024).toFixed(1)} MB`) + if (data.skippedFrames) console.log(` ${bold("Dropped:")} ${data.skippedFrames}/${data.totalFrames} frames`) + } catch (e: any) { + prompts.log.error(e.message) + } + } else { + const sp = prompts.spinner() + sp.start(`${args.action === "start" ? "Starting" : "Stopping"} stream…`) + try { + await obsFetch(`/api/obs/stream/${args.action}`, "POST") + sp.stop(success(`Stream ${args.action === "start" ? "started" : "stopped"}`)) + } catch (e: any) { + sp.stop("Failed") + prompts.log.error(e.message) + } + } + }, +}) + +// ── record start|stop|status ── + +const RecordCmd = cmd({ + command: "record ", + aliases: ["rec"], + describe: "control recording (start|stop|status)", + builder: (y) => y.positional("action", { type: "string", choices: ["start", "stop", "status"], demandOption: true }), + async handler(args) { + UI.empty() + if (args.action === "status") { + try { + const data = await obsFetch("/api/obs/record/status") + prompts.intro("◈ Recording Status") + console.log(` ${bold("Active:")} ${data.active ? success("RECORDING") : dim("stopped")}`) + if (data.paused) console.log(` ${bold("Paused:")} yes`) + if (data.timecode) console.log(` ${bold("Timecode:")} ${data.timecode}`) + } catch (e: any) { + prompts.log.error(e.message) + } + } else { + const sp = prompts.spinner() + sp.start(`${args.action === "start" ? "Starting" : "Stopping"} recording…`) + try { + const result = await obsFetch(`/api/obs/record/${args.action}`, "POST") + sp.stop(success(`Recording ${args.action === "start" ? "started" : "stopped"}`)) + if (result.outputPath) console.log(` ${dim("File:")} ${result.outputPath}`) + } catch (e: any) { + sp.stop("Failed") + prompts.log.error(e.message) + } + } + }, +}) + +// ── marker ── + +const MarkerCmd = cmd({ + command: "marker [description]", + aliases: ["mark"], + describe: "create a stream/recording marker (for highlights)", + builder: (y) => y.positional("description", { type: "string", default: "Marker" }), + async handler(args) { + UI.empty() + try { + const result = await obsFetch("/api/obs/marker", "POST", { description: args.description }) + const m = result.marker + console.log(` ${success("●")} Marker at ${bold(m.timecode)} — ${m.description}`) + } catch (e: any) { + prompts.log.error(e.message) + } + }, +}) + +// ── mute ── + +const MuteCmd = cmd({ + command: "mute ", + describe: "toggle mute on an audio input", + builder: (y) => + y + .positional("input", { type: "string", demandOption: true }) + .option("unmute", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + try { + const result = await obsFetch("/api/obs/audio/mute", "POST", { + input: args.input, + muted: !args.unmute, + }) + console.log(` ${result.muted ? dim("🔇 Muted") : success("🔊 Unmuted")}: ${bold(args.input as string)}`) + } catch (e: any) { + prompts.log.error(e.message) + } + }, +}) + +// ── inputs ── + +const InputsCmd = cmd({ + command: "inputs", + aliases: ["sources"], + describe: "list audio/video inputs", + async handler() { + UI.empty() + prompts.intro("◈ OBS Inputs") + try { + const inputs = await obsFetch("/api/obs/inputs") + printDivider() + for (const i of inputs) { + console.log(` ${highlight(i.name)} ${dim(i.kind || "")}`) + } + printDivider() + } catch (e: any) { + prompts.log.error(e.message) + } + prompts.outro("Done") + }, +}) + +// ── status ── + +const StatusCmd = cmd({ + command: "status", + describe: "full OBS status (connection + stream + recording)", + async handler() { + UI.empty() + prompts.intro("◈ OBS Status") + try { + const health = await fetch(`${BRIDGE}/health`, { signal: AbortSignal.timeout(3000) }).then(r => r.json()) + const obs = health?.messaging?.obs ?? health?.obs ?? { status: "stopped" } + console.log(` ${bold("Connection:")} ${obs.status === "running" ? success("connected") : dim("disconnected")}`) + if (obs.host) console.log(` ${bold("Host:")} ${dim(obs.host)}`) + + if (obs.status === "running") { + try { + const stream = await obsFetch("/api/obs/stream/status") + console.log(` ${bold("Stream:")} ${stream.active ? success("LIVE") : dim("offline")}`) + if (stream.timecode) console.log(` ${bold("Uptime:")} ${stream.timecode}`) + } catch {} + try { + const rec = await obsFetch("/api/obs/record/status") + console.log(` ${bold("Recording:")} ${rec.active ? success("RECORDING") : dim("stopped")}`) + } catch {} + try { + const scenes = await obsFetch("/api/obs/scenes") + console.log(` ${bold("Scene:")} ${highlight(scenes.current || "?")}`) + } catch {} + } + } catch (e: any) { + prompts.log.error(`Bridge not running: ${e.message}`) + } + prompts.outro("Done") + }, +}) + +// ============================================================================ +// Root +// ============================================================================ + +export const PlatformObsCommand = cmd({ + command: "obs", + describe: "control OBS Studio — scenes, streaming, recording, markers, audio", + builder: (y) => + y + .command(ConnectCmd) + .command(DisconnectCmd) + .command(ScenesCmd) + .command(SceneCmd) + .command(StreamCmd) + .command(RecordCmd) + .command(MarkerCmd) + .command(MuteCmd) + .command(InputsCmd) + .command(StatusCmd) + .demandCommand(1, "specify: connect, scenes, scene, stream, record, marker, mute, inputs, status"), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 9e7fbebb4710..dd93283ec712 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -70,6 +70,7 @@ import { PlatformAtlasCommsCommand } from "./cli/cmd/platform-atlas-comms" import { PlatformLeadsMeetingCommand } from "./cli/cmd/platform-leads-meeting" import { PlatformCampaignCommand } from "./cli/cmd/platform-campaign" import { PlatformDaemonCommand } from "./cli/cmd/platform-daemon" +import { PlatformObsCommand } from "./cli/cmd/platform-obs" import { PlatformDoctorCommand } from "./cli/cmd/platform-doctor" import { PlatformOnboardCommand } from "./cli/cmd/platform-onboard" import { PlatformProposalsCommand } from "./cli/cmd/platform-proposals" @@ -237,6 +238,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformCampaignCommand)) .command(reg(PlatformDaemonCommand)) .command(reg(PlatformDoctorCommand)) + .command(reg(PlatformObsCommand)) .command(reg(PlatformOnboardCommand)) .command(reg(PlatformProposalsCommand)) .command(reg(PlatformContractsCommand)) From 10fa78632f02deba403e63ec129fb17382fae55b Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 08:35:34 -0500 Subject: [PATCH 04/24] test: calendar event display + fix stale test assertions (#58778/#58779) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/test/cli/bug-fixes.test.ts | 41 ++ .../test/platform/bug-regressions.test.ts | 466 ++++++++++++++++++ 2 files changed, 507 insertions(+) create mode 100644 packages/opencode/test/platform/bug-regressions.test.ts diff --git a/packages/opencode/test/cli/bug-fixes.test.ts b/packages/opencode/test/cli/bug-fixes.test.ts index 24607f554e14..54acf0d4d35d 100644 --- a/packages/opencode/test/cli/bug-fixes.test.ts +++ b/packages/opencode/test/cli/bug-fixes.test.ts @@ -385,3 +385,44 @@ describe("completeness score in source (#57686)", () => { expect(source).toContain("maskSecrets") }) }) + +// ============================================================================ +// #58778/#58779: Calendar event display — summary + start time +// ============================================================================ + +describe("calendar event display (#58778, #58779)", () => { + // Replicate displayArrayItems logic for calendar events + function getEventLabel(item: Record): string { + return String(item.name ?? item.title ?? item.summary ?? item.subject ?? item.id ?? "") + } + + function getStartTime(item: Record): string { + const rawStart = item.start + return typeof rawStart === "string" ? rawStart : ((rawStart as any)?.dateTime ?? (rawStart as any)?.date ?? "") + } + + test("uses summary field for Google Calendar events", () => { + const event = { id: "abc123", summary: "Song Wars Ep.1", start: "2026-04-18T19:00:00-05:00" } + expect(getEventLabel(event)).toBe("Song Wars Ep.1") + }) + + test("extracts start time from flat string", () => { + const event = { summary: "Test", start: "2026-04-18T09:00:00-05:00" } + expect(getStartTime(event)).toBe("2026-04-18T09:00:00-05:00") + }) + + test("extracts start time from nested object", () => { + const event = { summary: "Test", start: { dateTime: "2026-04-18T09:00:00Z" } } + expect(getStartTime(event)).toBe("2026-04-18T09:00:00Z") + }) + + test("handles missing start gracefully", () => { + const event = { summary: "Test" } + expect(getStartTime(event)).toBe("") + }) + + test("falls back to id when no summary/title/name", () => { + const event = { id: "7d4kh6431gmu" } + expect(getEventLabel(event)).toBe("7d4kh6431gmu") + }) +}) diff --git a/packages/opencode/test/platform/bug-regressions.test.ts b/packages/opencode/test/platform/bug-regressions.test.ts new file mode 100644 index 000000000000..c1fb988d0e35 --- /dev/null +++ b/packages/opencode/test/platform/bug-regressions.test.ts @@ -0,0 +1,466 @@ +/** + * Regression tests for Bloq #297 bug fixes (April 2026) + * + * These tests prevent recurrence of every bug class found during the + * client-facing QA session. Each test is tagged with its bug item ID. + * + * Categories: + * 1. Display / rendering — [object], truncated output, missing timestamps + * 2. Exit codes — non-zero on failures + * 3. --json output cleanliness — no ANSI, no spinner, no clack headers + * 4. Search / resolution — multi-word, name picker, non-interactive fallback + * 5. Channel routing — Gmail endpoint, message limits, 0-channel warning + * 6. URL extraction — shared links surfaced from messages + * 7. Endpoint contracts — correct API paths for pulse, gmail, pages + */ +import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test" +import { readFileSync } from "fs" +import { join } from "path" + +const SRC_DIR = join(import.meta.dir, "../../src/cli/cmd") +function readSource(filename: string): string { + return readFileSync(join(SRC_DIR, filename), "utf-8") +} + +// ============================================================================ +// 1. Display / rendering — #55730, #55622, #55740 +// ============================================================================ + +describe("displayResult rendering (#55730)", () => { + test("platform-run.ts: displayResult expands arrays with name/title/id", () => { + const src = readSource("platform-run.ts") + // displayArrayItems must exist and handle name, title, mimeType + expect(src).toContain("function displayArrayItems") + expect(src).toContain("item.name") + expect(src).toContain("item.title") + expect(src).toContain("item.mimeType") + }) + + test("platform-run.ts: displayResult drills into nested objects for arrays", () => { + const src = readSource("platform-run.ts") + // Must detect nested arrays inside objects (e.g. data.files) + expect(src).toContain("nestedArrays") + expect(src).toContain("Array.isArray(val)") + }) + + test("displayResult does not show raw [object] for integration responses", () => { + const src = readSource("platform-run.ts") + // The old bug: showing [object] for nested data + // The new code should not have a plain "[object]" fallback + const displayBlock = src.slice( + src.indexOf("function displayResult"), + src.indexOf("function displayResult") + 2000, + ) + expect(displayBlock).not.toContain('"[object]"') + }) + + test("displayArrayItems caps at 25 items with overflow message", () => { + const src = readSource("platform-run.ts") + expect(src).toContain("items.slice(0, 25)") + expect(src).toContain("items.length > 25") + }) +}) + +describe("Apple Mail timestamps (#55622, #55740)", () => { + test("platform-leads.ts: Apple Mail timestamp has fallback for blank dates", () => { + const src = readSource("platform-leads.ts") + // Must have a fallback when date is empty + expect(src).toContain('msg.date ?? msg.ts ?? dim("(no date)")') + }) + + test("bridge: AppleScript date field is wrapped in try/on error", () => { + const bridgePath = join(import.meta.dir, "../../../../../fl-docker-dev/coding-agent-bridge/index.js") + let bridgeSrc: string + try { + bridgeSrc = readFileSync(bridgePath, "utf-8") + } catch { + // Bridge lives outside iris-code — skip if not available + console.log(` (skipped — bridge not found at ${bridgePath})`) + return + } + // The date received getter must be inside a try block + const mailSearchBlock = bridgeSrc.slice( + bridgeSrc.indexOf("GET /api/mail/search"), + bridgeSrc.indexOf("GET /api/mail/search") + 3000, + ) + expect(mailSearchBlock).toContain("try") + expect(mailSearchBlock).toContain("set theDate to (date received of msg)") + expect(mailSearchBlock).toContain("on error") + }) +}) + +// ============================================================================ +// 2. Exit codes — #55722 +// ============================================================================ + +describe("non-zero exit codes on failure (#55722)", () => { + test("leads get: sets process.exitCode = 1 on API failure", () => { + const src = readSource("platform-leads.ts") + // The leads get handler must set exitCode on failure + const getBlock = src.slice( + src.indexOf('command: "get "'), + src.indexOf('command: "get "') + 3000, + ) + expect(getBlock).toContain("process.exitCode = 1") + }) + + test("leads pulse: sets process.exitCode = 1 on API failure", () => { + const src = readSource("platform-leads.ts") + const pulseBlock = src.slice( + src.indexOf('command: "pulse "'), + src.indexOf('command: "pulse "') + 4000, + ) + expect(pulseBlock).toContain("process.exitCode = 1") + }) + + test("leads get: exits non-zero when lead not found (null response)", () => { + const src = readSource("platform-leads.ts") + // Must check for empty lead data + expect(src).toContain("if (!l || !l.id)") + expect(src).toContain('"Lead not found"') + }) + + test("pages list: sets process.exitCode = 1 on failure", () => { + const src = readSource("platform-pages.ts") + const listBlock = src.slice( + src.indexOf('command: "list"'), + src.indexOf('command: "list"') + 1000, + ) + expect(listBlock).toContain("process.exitCode = 1") + }) +}) + +// ============================================================================ +// 3. --json output cleanliness — #55735 +// ============================================================================ + +describe("--json clean output (#55735)", () => { + test("exec handler: skips UI.empty() and prompts.intro() when --json", () => { + const src = readSource("platform-run.ts") + // The handler must gate UI chrome behind !args.json + const handlerBlock = src.slice( + src.indexOf('command: "exec '), + src.indexOf('command: "exec ') + 1000, + ) + expect(handlerBlock).toContain("if (!args.json)") + expect(handlerBlock).toContain("UI.empty()") + }) + + test("exec integration: --json path has no spinner", () => { + const src = readSource("platform-run.ts") + // The --json early return for integrations must not reference spinner + const jsonBlock = src.slice( + src.indexOf("Skip spinner/ANSI when --json"), + src.indexOf("Skip spinner/ANSI when --json") + 300, + ) + expect(jsonBlock).toContain("console.log(JSON.stringify(result, null, 2))") + expect(jsonBlock).not.toContain("spinner.start") + }) + + test("exec system tool: --json path outputs clean JSON", () => { + const src = readSource("platform-run.ts") + // Clean JSON output for tool execution + expect(src).toContain("Clean JSON output — no spinner/ANSI") + }) +}) + +// ============================================================================ +// 4. Search / resolution — #55719, #55742, leads search multi-word +// ============================================================================ + +describe("name picker non-interactive fallback (#55719, #55742)", () => { + test("leads get: auto-selects first match in non-interactive mode", () => { + const src = readSource("platform-leads.ts") + const getBlock = src.slice( + src.indexOf('command: "get "'), + src.indexOf('command: "get "') + 3000, + ) + expect(getBlock).toContain("isNonInteractive()") + }) + + test("leads pulse: auto-selects first match in non-interactive mode", () => { + const src = readSource("platform-leads.ts") + const pulseBlock = src.slice( + src.indexOf('command: "pulse "'), + src.indexOf('command: "pulse "') + 4000, + ) + expect(pulseBlock).toContain("isNonInteractive()") + }) + + test("leads notes: auto-selects first match in non-interactive mode", () => { + const src = readSource("platform-leads.ts") + const notesBlock = src.slice( + src.indexOf('command: "notes "'), + src.indexOf('command: "notes "') + 2000, + ) + expect(notesBlock).toContain("isNonInteractive()") + }) +}) + +describe("multi-word search fallback", () => { + test("leads search: splits multi-word queries when initial search returns 0", () => { + const src = readSource("platform-leads.ts") + const searchBlock = src.slice( + src.indexOf('command: "search "'), + src.indexOf('command: "search "') + 3000, + ) + // Must detect multi-word and try individual terms + expect(searchBlock).toContain('args.query.includes(" ")') + expect(searchBlock).toContain("split(/\\s+/)") + }) + + test("multi-word fallback filters results by ALL search terms", () => { + const src = readSource("platform-leads.ts") + // Must filter to only leads matching all words + expect(src).toContain("allWords.every") + }) + + test("multi-word fallback logic: matches all words case-insensitively", () => { + // Simulate the matching logic + const allWords = ["andrew", "gearhart"] + const lead = { name: "Andrew Gearhart", email: "a@test.com", company: "Acme" } + const haystack = `${lead.name} ${lead.email} ${lead.company}`.toLowerCase() + const matches = allWords.every((w) => haystack.includes(w)) + expect(matches).toBe(true) + }) + + test("multi-word fallback: skips short words (< 3 chars)", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("word.length < 3") + }) +}) + +// ============================================================================ +// 5. Channel routing — #55620, #55720, #55721, #55723, #55737, #55738 +// ============================================================================ + +describe("leads pulse Gmail routing (#55620, #55737, #55738)", () => { + test("pulse does NOT use MCP gmail/execute endpoint (was causing 422)", () => { + const src = readSource("platform-leads.ts") + const pulseBlock = src.slice( + src.indexOf('command: "pulse "'), + src.indexOf("// ====", src.indexOf('command: "pulse "') + 100), + ) + // Must NOT use the old MCP endpoint + expect(pulseBlock).not.toContain("/api/v1/mcp/gmail/execute") + }) + + test("pulse uses lead-specific Gmail threads endpoint", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("/api/v1/leads/${leadId}/gmail-threads") + }) + + test("pulse Gmail filters results by lead email (#55723, #55743)", () => { + const src = readSource("platform-leads.ts") + // Must have client-side email matching filter + expect(src).toContain("email.toLowerCase()") + expect(src).toContain("fromLower.includes") + }) +}) + +describe("message limits (#55620, #55621, #55720, #55739)", () => { + test("iMessage search: default limit is 50 (not 20)", () => { + const src = readSource("platform-imessage.ts") + const searchBlock = src.slice( + src.indexOf('command: "search "'), + src.indexOf('command: "search "') + 500, + ) + // Default should be 50 + expect(searchBlock).toContain("default: 50") + expect(searchBlock).not.toMatch(/default:\s*20/) + }) + + test("iMessage chats: default limit is 50 (not 20)", () => { + const src = readSource("platform-imessage.ts") + const chatsBlock = src.slice( + src.indexOf('command: "chats"'), + src.indexOf('command: "chats"') + 500, + ) + expect(chatsBlock).toContain("default: 50") + }) + + test("pulse Gmail: no artificial Math.min cap on results", () => { + const src = readSource("platform-leads.ts") + const pulseBlock = src.slice( + src.indexOf("Gmail threads endpoint"), + src.indexOf("Gmail threads endpoint") + 500, + ) + // Must NOT have Math.min(msgLimit, 20) + expect(pulseBlock).not.toContain("Math.min(msgLimit, 20)") + }) + + test("pulse Apple Mail: no artificial Math.min cap", () => { + const src = readSource("platform-leads.ts") + const pulseBlock = src.slice( + src.indexOf('command: "pulse "'), + src.indexOf("// ====", src.indexOf('command: "pulse "') + 100), + ) + // Must NOT have the old Math.min(msgLimit, 100) + expect(pulseBlock).not.toContain("Math.min(msgLimit, 100)") + }) +}) + +describe("zero channels warning (#55721)", () => { + test("pulse warns when lead has no email AND no phone", () => { + const src = readSource("platform-leads.ts") + // Must have explicit check + expect(src).toContain("!email && !phone") + expect(src).toContain("No channels available") + expect(src).toContain("no email or phone") + }) +}) + +// ============================================================================ +// 6. URL extraction — #55733, #55741 +// ============================================================================ + +describe("shared links extraction (#55733, #55741)", () => { + test("pulse extracts URLs from channel messages", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("Shared Links") + expect(src).toContain("urlRegex") + }) + + test("URL regex matches http and https links", () => { + const urlRegex = /https?:\/\/[^\s<>"')\]]+/g + const text = "Check https://docs.google.com/spreadsheets/d/abc123 and http://example.com" + const matches = text.match(urlRegex) + expect(matches).toBeTruthy() + expect(matches!.length).toBe(2) + expect(matches![0]).toContain("docs.google.com") + }) + + test("URL extraction deduplicates links", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("sharedLinks.some((l) => l.url === url)") + }) + + test("URL extraction includes channel source", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("channel: ch.name") + }) +}) + +// ============================================================================ +// 7. Subcommand completeness — leads notes, integrations list-connected +// ============================================================================ + +describe("leads notes subcommand", () => { + test("LeadsNotesCommand exists with 'notes ' command", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain('command: "notes "') + }) + + test("notes command is registered in parent leads command", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain(".command(LeadsNotesCommand)") + }) + + test("notes command accepts name or ID (not just numeric)", () => { + const src = readSource("platform-leads.ts") + const notesBlock = src.slice( + src.indexOf('command: "notes "'), + src.indexOf('command: "notes "') + 2000, + ) + expect(notesBlock).toContain("isNaN(leadId)") + }) +}) + +describe("integrations list-connected status honesty (#55734)", () => { + test("list-connected includes disclaimer about [active] meaning", () => { + const src = readSource("platform-run.ts") + // Verify list-connected renders status labels + expect(src).toContain("[active]") + }) +}) + +// ============================================================================ +// 8. Endpoint URL contracts — pulse, gmail, pages +// ============================================================================ + +describe("endpoint URL contracts: leads pulse", () => { + test("pulse fetches lead details from /api/v1/leads/{id}", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("/api/v1/leads/${leadId}") + }) + + test("pulse uses /api/v1/leads/{id}/gmail-threads for Gmail", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("/api/v1/leads/${leadId}/gmail-threads") + }) + + test("pulse uses bridge /api/imessage/search for iMessage", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("${BRIDGE_BASE}/api/imessage/search") + }) + + test("pulse uses bridge /api/mail/search for Apple Mail", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("${BRIDGE_BASE}/api/mail/search") + }) +}) + +describe("endpoint URL contracts: pages", () => { + test("pages list uses iris-api (not fl-api)", () => { + const src = readSource("platform-pages.ts") + expect(src).toContain("irisFetch(path, options ?? {}, IRIS_API)") + }) + + test("pages list requests /api/v1/pages", () => { + const src = readSource("platform-pages.ts") + expect(src).toContain("/api/v1/pages?per_page=50") + }) +}) + +// ============================================================================ +// 9. agents create --type flag — #B-04 +// ============================================================================ + +describe("agents create --type flag (B-04)", () => { + test("agents create builder includes --type option", () => { + const src = readSource("platform-agents.ts") + const createBlock = src.slice( + src.indexOf('command: "create"'), + src.indexOf('command: "create"') + 1000, + ) + expect(createBlock).toContain('.option("type"') + }) + + test("agents create payload includes type field", () => { + const src = readSource("platform-agents.ts") + expect(src).toContain('type: args.type ?? "content"') + }) + + test("agents create defaults type to 'content'", () => { + const src = readSource("platform-agents.ts") + const createBlock = src.slice( + src.indexOf('command: "create"'), + src.indexOf('command: "create"') + 800, + ) + expect(createBlock).toContain('default: "content"') + }) +}) + +// ============================================================================ +// 10. Model safety — must never use gpt-3.5-turbo +// ============================================================================ + +describe("model safety across all platform commands", () => { + const PLATFORM_FILES = [ + "platform-leads.ts", + "platform-run.ts", + "platform-agents.ts", + "platform-chat.ts", + "platform-pages.ts", + "platform-imessage.ts", + "platform-mail.ts", + ] + + for (const file of PLATFORM_FILES) { + test(`${file}: does not reference gpt-3.5-turbo`, () => { + const src = readSource(file) + expect(src).not.toContain("gpt-3.5-turbo") + expect(src).not.toContain("gpt-3.5") + }) + } +}) From 65e201314c79638b3fa2906c59dd9d83e1b5080d Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 08:50:34 -0500 Subject: [PATCH 05/24] feat: tickets-push --force/--yes flag for automation (#58781) Skip interactive confirmation prompt when --force or -y is passed. Enables tickets-push in scripts and CI without TTY. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/platform-events.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index e25f524dda5e..004f92cba1b6 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -920,7 +920,8 @@ const TicketsPushCommand = cmd({ yargs .positional("event-id", { describe: "event ID", type: "number", demandOption: true }) .option("file", { alias: "f", describe: "local JSON file path", type: "string" }) - .option("dry-run", { describe: "show what would change without applying", type: "boolean", default: false }), + .option("dry-run", { describe: "show what would change without applying", type: "boolean", default: false }) + .option("force", { alias: "y", describe: "skip confirmation prompt (for automation)", type: "boolean", default: false }), async handler(args) { UI.empty() prompts.intro(`◈ Push Tickets — Event #${args["event-id"]}`) @@ -1030,9 +1031,11 @@ const TicketsPushCommand = cmd({ return } - // 5. Confirm and apply - const confirmed = await prompts.confirm({ message: "Apply these changes?" }) - if (!confirmed || prompts.isCancel(confirmed)) { prompts.outro("Cancelled"); return } + // 5. Confirm and apply (skip with --force for automation) + if (!args.force) { + const confirmed = await prompts.confirm({ message: "Apply these changes?" }) + if (!confirmed || prompts.isCancel(confirmed)) { prompts.outro("Cancelled"); return } + } const applySpinner = prompts.spinner() applySpinner.start("Applying…") From 73ff175c0cc6bd04d96e394d8150e8b286ba9d3a Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 09:11:30 -0500 Subject: [PATCH 06/24] =?UTF-8?q?feat:=20iris=20events=20preflight=20+=20a?= =?UTF-8?q?udit=20=E2=80=94=20production=20readiness=20QA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preflight: live system checks before going live (OBS connection, scenes, stream/record status, cameras, audio, bridge health, ticket checkout URLs, event page, venue, vendors). Cross-references event stages against OBS scenes. audit: data completeness checks (title, description, dates, venue, address, photo, stages, lineup, tickets priced/active/checkout URLs/QR codes, vendors, landing page). Scores as percentage with actionable fix commands. Both output grouped by category with ✓/✗ indicators and fix hints. Exit non-zero when checks fail. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/platform-events.ts | 330 +++++++++++++++++- 1 file changed, 329 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index 004f92cba1b6..f8c1539cbe5e 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -1288,13 +1288,338 @@ const TicketCheckoutCommand = cmd({ }, }) +// ============================================================================ +// Preflight — live system checks before going live +// ============================================================================ + +const BRIDGE = "http://localhost:3200" + +interface PCheck { name: string; ok: boolean; detail?: string; hint?: string; category: string } + +const PreflightCommand = cmd({ + command: "preflight ", + aliases: ["pre", "go-check"], + describe: "production readiness check — verify OBS, stream, tickets, bridge before going live", + builder: (y) => + y.positional("event-id", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Event #${args["event-id"]} — Preflight Check`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const sp = prompts.spinner() + sp.start("Loading event…") + + // Fetch event data + const eventRes = await irisFetch(`/api/v1/events/${args["event-id"]}`) + if (!eventRes.ok) { await handleApiError(eventRes, "Get event"); sp.stop("Failed", 1); prompts.outro("Done"); return } + const event = ((await eventRes.json()) as any)?.data ?? {} + sp.stop(bold(event.title || `Event #${args["event-id"]}`)) + + // Fetch sub-resources in parallel + const [stagesRes, ticketsRes, vendorsRes] = await Promise.all([ + irisFetch(`/api/v1/events/${args["event-id"]}/stages`).catch(() => null), + irisFetch(`/api/v1/events/${args["event-id"]}/tickets`).catch(() => null), + irisFetch(`/api/v1/events/${args["event-id"]}/vendors`).catch(() => null), + ]) + const stages: any[] = stagesRes?.ok ? ((await stagesRes.json()) as any)?.data ?? [] : [] + const tickets: any[] = ticketsRes?.ok ? ((await ticketsRes.json()) as any)?.data ?? [] : [] + const vendors: any[] = vendorsRes?.ok ? ((await vendorsRes.json()) as any)?.data ?? [] : [] + + sp.start("Running checks…") + + const checks: PCheck[] = [] + + // ── Production checks (OBS + Bridge) ── + let obsConnected = false + let obsScenes: string[] = [] + let obsStreamActive = false + let obsRecordActive = false + let obsInputs: any[] = [] + + try { + const health = await fetch(`${BRIDGE}/health`, { signal: AbortSignal.timeout(3000) }).then(r => r.json()) + checks.push({ name: "IRIS Bridge", ok: true, detail: "running", category: "Production" }) + + const obs = health?.messaging?.obs ?? health?.obs ?? {} + obsConnected = obs?.status === "running" + checks.push({ + name: "OBS connected", + ok: obsConnected, + detail: obsConnected ? obs.host : "not connected", + hint: obsConnected ? undefined : "iris obs connect", + category: "Production", + }) + } catch { + checks.push({ name: "IRIS Bridge", ok: false, hint: "iris hive start", category: "Production" }) + checks.push({ name: "OBS connected", ok: false, hint: "start bridge first", category: "Production" }) + } + + if (obsConnected) { + try { + const scenes = await fetch(`${BRIDGE}/api/obs/scenes`, { signal: AbortSignal.timeout(3000) }).then(r => r.json()) + obsScenes = (scenes.scenes || []).map((s: any) => s.name) + const matched = stages.filter(s => obsScenes.some(os => os.toLowerCase().includes(s.title?.toLowerCase() || "___"))) + checks.push({ + name: "Scenes match stages", + ok: matched.length > 0 || stages.length === 0, + detail: `${obsScenes.length} scenes, ${matched.length}/${stages.length} stages matched`, + hint: matched.length === 0 && stages.length > 0 ? "iris obs scenes — map stages to OBS scenes" : undefined, + category: "Production", + }) + checks.push({ name: "Current scene", ok: true, detail: scenes.current || "?", category: "Production" }) + } catch {} + + try { + const stream = await fetch(`${BRIDGE}/api/obs/stream/status`, { signal: AbortSignal.timeout(3000) }).then(r => r.json()) + obsStreamActive = stream.active + checks.push({ + name: "Streaming", + ok: true, + detail: stream.active ? `LIVE — ${stream.timecode}` : "not streaming (ready)", + category: "Production", + }) + } catch {} + + try { + const rec = await fetch(`${BRIDGE}/api/obs/record/status`, { signal: AbortSignal.timeout(3000) }).then(r => r.json()) + obsRecordActive = rec.active + checks.push({ + name: "Recording", + ok: true, + detail: rec.active ? `recording — ${rec.timecode}` : "not recording (ready)", + category: "Production", + }) + } catch {} + + try { + obsInputs = await fetch(`${BRIDGE}/api/obs/inputs`, { signal: AbortSignal.timeout(3000) }).then(r => r.json()) + const cameras = obsInputs.filter((i: any) => i.kind?.includes("capture") && !i.kind?.includes("audio") && !i.kind?.includes("screen")) + const mics = obsInputs.filter((i: any) => i.kind?.includes("audio")) + checks.push({ name: "Cameras detected", ok: cameras.length > 0, detail: `${cameras.length} camera(s)`, category: "Production" }) + checks.push({ name: "Audio inputs", ok: mics.length > 0, detail: `${mics.length} mic(s)`, category: "Production" }) + } catch {} + } + + // ── Tickets ── + checks.push({ + name: "Tickets created", + ok: tickets.length > 0, + detail: tickets.length > 0 ? `${tickets.length} tier(s): ${tickets.map((t: any) => `$${t.price}`).join(" / ")}` : "none", + hint: tickets.length === 0 ? `iris events ticket-create ${args["event-id"]}` : undefined, + category: "Tickets", + }) + + const onSale = tickets.filter((t: any) => t.status === "active" && t.is_visible) + checks.push({ + name: "Tickets on sale", + ok: onSale.length > 0, + detail: `${onSale.length}/${tickets.length} on sale`, + hint: onSale.length === 0 ? "activate tickets in admin" : undefined, + category: "Tickets", + }) + + // Check checkout URLs + for (const t of tickets.slice(0, 3)) { + if (t.checkout_url) { + try { + const r = await fetch(t.checkout_url, { method: "HEAD", redirect: "follow", signal: AbortSignal.timeout(5000) }) + checks.push({ name: `Checkout: ${t.title}`, ok: r.status < 400, detail: `${r.status}`, category: "Tickets" }) + } catch { + checks.push({ name: `Checkout: ${t.title}`, ok: false, detail: "unreachable", category: "Tickets" }) + } + } + } + + // ── Content ── + const slug = event.slug || event.title?.toLowerCase().replace(/[^a-z0-9]+/g, "-") + if (slug) { + try { + const pageRes = await fetch(`https://heyiris.io/p/${slug}`, { method: "HEAD", signal: AbortSignal.timeout(5000) }) + checks.push({ name: "Event page", ok: pageRes.status === 200, detail: pageRes.status === 200 ? `heyiris.io/p/${slug}` : `HTTP ${pageRes.status}`, hint: pageRes.status !== 200 ? `iris pages create --slug=${slug}` : undefined, category: "Content" }) + } catch { + checks.push({ name: "Event page", ok: false, hint: `iris pages create --slug=${slug}`, category: "Content" }) + } + } + + // ── Logistics ── + checks.push({ + name: "Stages defined", + ok: stages.length > 0, + detail: `${stages.length} stage(s)`, + hint: stages.length === 0 ? `iris events stage-create ${args["event-id"]}` : undefined, + category: "Logistics", + }) + checks.push({ + name: "Vendors confirmed", + ok: vendors.length > 0, + detail: `${vendors.length} vendor(s)`, + hint: vendors.length === 0 ? `iris events vendor-create ${args["event-id"]}` : undefined, + category: "Logistics", + }) + checks.push({ + name: "Venue set", + ok: !!(event.venue_name && event.city), + detail: event.venue_name ? `${event.venue_name}, ${event.city}` : "missing", + hint: !event.venue_name ? `iris events update ${args["event-id"]} --venue="..."` : undefined, + category: "Logistics", + }) + + sp.stop("Done") + + // ── Render ── + if (args.json) { + console.log(JSON.stringify(checks, null, 2)) + prompts.outro("Done") + return + } + + const categories = [...new Set(checks.map(c => c.category))] + const passing = checks.filter(c => c.ok).length + const total = checks.length + const pct = Math.round((passing / total) * 100) + + for (const cat of categories) { + const catChecks = checks.filter(c => c.category === cat) + const catPass = catChecks.filter(c => c.ok).length + console.log() + console.log(` ${bold(cat)} ${dim(`(${catPass}/${catChecks.length})`)}`) + for (const c of catChecks) { + const icon = c.ok ? success("✓") : `${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL}` + const detail = c.detail ? dim(` ${c.detail}`) : "" + const hint = (!c.ok && c.hint) ? ` ${dim(`→ ${c.hint}`)}` : "" + console.log(` ${icon} ${c.name.padEnd(22)}${detail}${hint}`) + } + } + + printDivider() + const color = pct >= 80 ? success : pct >= 50 ? (s: string) => `${UI.Style.TEXT_WARNING}${s}${UI.Style.TEXT_NORMAL}` : (s: string) => `${UI.Style.TEXT_DANGER}${s}${UI.Style.TEXT_NORMAL}` + console.log(` Readiness: ${color(`${pct}%`)} (${passing}/${total})`) + + if (pct < 100) process.exitCode = 1 + prompts.outro("Done") + }, +}) + +// ============================================================================ +// Audit — data completeness + professional quality checks +// ============================================================================ + +const AuditCommand = cmd({ + command: "audit ", + aliases: ["qa", "check"], + describe: "data completeness audit — check all fields, stages, tickets, staff, content quality", + builder: (y) => + y.positional("event-id", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Event #${args["event-id"]} — Audit`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const sp = prompts.spinner() + sp.start("Loading event…") + + const eventRes = await irisFetch(`/api/v1/events/${args["event-id"]}`) + if (!eventRes.ok) { await handleApiError(eventRes, "Get event"); sp.stop("Failed", 1); prompts.outro("Done"); return } + const event = ((await eventRes.json()) as any)?.data ?? {} + + const [stagesRes, ticketsRes, vendorsRes] = await Promise.all([ + irisFetch(`/api/v1/events/${args["event-id"]}/stages`).catch(() => null), + irisFetch(`/api/v1/events/${args["event-id"]}/tickets`).catch(() => null), + irisFetch(`/api/v1/events/${args["event-id"]}/vendors`).catch(() => null), + ]) + const stages: any[] = stagesRes?.ok ? ((await stagesRes.json()) as any)?.data ?? [] : [] + const tickets: any[] = ticketsRes?.ok ? ((await ticketsRes.json()) as any)?.data ?? [] : [] + const vendors: any[] = vendorsRes?.ok ? ((await vendorsRes.json()) as any)?.data ?? [] : [] + + sp.stop(bold(event.title || `Event #${args["event-id"]}`)) + + const checks: PCheck[] = [] + const eid = args["event-id"] + + // ── Event Details ── + checks.push({ name: "Title", ok: !!event.title, detail: event.title || "missing", hint: `iris events update ${eid} --title="..."`, category: "Details" }) + checks.push({ name: "Description", ok: !!(event.description && event.description.length > 20), detail: event.description ? `${event.description.length} chars` : "missing", hint: `iris events update ${eid} --description="..."`, category: "Details" }) + checks.push({ name: "Date set", ok: !!event.start_date, detail: event.start_date || "missing", hint: `iris events update ${eid} --date=YYYY-MM-DD`, category: "Details" }) + checks.push({ name: "Time set", ok: !!event.start_time, detail: event.start_time || "missing", category: "Details" }) + checks.push({ name: "Venue", ok: !!event.venue_name, detail: event.venue_name || "missing", hint: `iris events update ${eid} --venue="..."`, category: "Details" }) + checks.push({ name: "Address", ok: !!(event.city && event.state), detail: event.city ? `${event.city}, ${event.state}` : "missing", category: "Details" }) + checks.push({ name: "Photo/banner", ok: !!event.photo, detail: event.photo ? "set" : "missing", category: "Details" }) + + // ── Stages & Lineup ── + checks.push({ name: "Stages defined", ok: stages.length > 0, detail: `${stages.length} stage(s)`, hint: `iris events stage-create ${eid}`, category: "Stages & Lineup" }) + const stagesWithSetTimes = stages.filter((s: any) => (s.set_times?.length || s.event_stage_set_times?.length || 0) > 0) + checks.push({ name: "Performers scheduled", ok: stagesWithSetTimes.length > 0 || stages.length === 0, detail: `${stagesWithSetTimes.length}/${stages.length} stages have lineup`, category: "Stages & Lineup" }) + + // ── Tickets & Sales ── + checks.push({ name: "Tickets created", ok: tickets.length > 0, detail: `${tickets.length} tier(s)`, hint: "create ticket tiers", category: "Tickets & Sales" }) + const priced = tickets.filter((t: any) => t.price && parseFloat(t.price) > 0) + checks.push({ name: "All priced", ok: priced.length === tickets.length && tickets.length > 0, detail: priced.length > 0 ? priced.map((t: any) => `${t.title}: $${t.price}`).join(", ") : "none", category: "Tickets & Sales" }) + const active = tickets.filter((t: any) => t.status === "active") + checks.push({ name: "Tickets active", ok: active.length > 0, detail: `${active.length}/${tickets.length} active`, category: "Tickets & Sales" }) + const withCheckout = tickets.filter((t: any) => t.checkout_url) + checks.push({ name: "Checkout URLs", ok: withCheckout.length === tickets.length && tickets.length > 0, detail: `${withCheckout.length}/${tickets.length} have URLs`, hint: `iris events ticket-checkout ${eid}`, category: "Tickets & Sales" }) + const withQR = tickets.filter((t: any) => t.qr_url) + checks.push({ name: "QR codes", ok: withQR.length > 0, detail: `${withQR.length}/${tickets.length} have QR`, hint: "QR auto-generated from checkout_url", category: "Tickets & Sales" }) + + // ── Vendors & Partners ── + checks.push({ name: "Vendors listed", ok: vendors.length > 0, detail: `${vendors.length} vendor(s)`, hint: `iris events vendor-create ${eid}`, category: "Vendors & Partners" }) + + // ── Content & Marketing ── + const slug = event.slug || event.title?.toLowerCase().replace(/[^a-z0-9]+/g, "-") + if (slug) { + try { + const pageRes = await fetch(`https://heyiris.io/p/${slug}`, { method: "HEAD", signal: AbortSignal.timeout(5000) }) + checks.push({ name: "Landing page", ok: pageRes.status === 200, detail: pageRes.status === 200 ? `heyiris.io/p/${slug}` : "not found", hint: `iris pages create --slug=${slug}`, category: "Content" }) + } catch { + checks.push({ name: "Landing page", ok: false, hint: `iris pages create --slug=${slug}`, category: "Content" }) + } + } + checks.push({ name: "Ticket URL on event", ok: !!event.purchase_ticket_url, detail: event.purchase_ticket_url ? "set" : "missing", category: "Content" }) + + // ── Render ── + if (args.json) { + console.log(JSON.stringify(checks, null, 2)) + prompts.outro("Done") + return + } + + const categories = [...new Set(checks.map(c => c.category))] + const passing = checks.filter(c => c.ok).length + const total = checks.length + const pct = Math.round((passing / total) * 100) + + for (const cat of categories) { + const catChecks = checks.filter(c => c.category === cat) + const catPass = catChecks.filter(c => c.ok).length + console.log() + console.log(` ${bold(cat)} ${dim(`(${catPass}/${catChecks.length})`)}`) + for (const c of catChecks) { + const icon = c.ok ? success("✓") : `${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL}` + const detail = c.detail ? dim(` ${c.detail}`) : "" + const hint = (!c.ok && c.hint) ? ` ${dim(`→ ${c.hint}`)}` : "" + console.log(` ${icon} ${c.name.padEnd(22)}${detail}${hint}`) + } + } + + printDivider() + const color = pct >= 80 ? success : pct >= 50 ? (s: string) => `${UI.Style.TEXT_WARNING}${s}${UI.Style.TEXT_NORMAL}` : (s: string) => `${UI.Style.TEXT_DANGER}${s}${UI.Style.TEXT_NORMAL}` + console.log(` Completeness: ${color(`${pct}%`)} (${passing}/${total})`) + + if (pct < 100) process.exitCode = 1 + prompts.outro("Done") + }, +}) + // ============================================================================ // Root command // ============================================================================ export const PlatformEventsCommand = cmd({ command: "events", - describe: "manage events, stages, vendors, tickets — pull, push, diff, CRUD", + describe: "manage events, stages, vendors, tickets — pull, push, diff, CRUD, preflight, audit", builder: (yargs) => yargs .command(ListCommand) @@ -1319,6 +1644,9 @@ export const PlatformEventsCommand = cmd({ .command(TicketsPushCommand) .command(TicketsDiffCommand) .command(TicketCheckoutCommand) + // Production QA + .command(PreflightCommand) + .command(AuditCommand) .demandCommand(), async handler() {}, }) From eaceaae534d947f36c5dd2675590b96d111fe37d Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 09:27:52 -0500 Subject: [PATCH 07/24] fix: expose update_event + delete_event in calendar CLI registry (#58787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend already had both methods — CLI just wasn't listing them. Now shows all 4 functions when running: iris integrations exec google-calendar Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/platform-run.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-run.ts b/packages/opencode/src/cli/cmd/platform-run.ts index 10f05b0bbfd3..e759f308f6bc 100644 --- a/packages/opencode/src/cli/cmd/platform-run.ts +++ b/packages/opencode/src/cli/cmd/platform-run.ts @@ -61,6 +61,8 @@ const INTEGRATION_FUNCTIONS: Record Date: Sat, 18 Apr 2026 10:04:41 -0500 Subject: [PATCH 08/24] fix: events push/diff picks tickets file instead of event file (#58783/#58784) findLocalFile matched any {id}-*.json including tickets files. Now excludes files containing 'tickets' in the name. Fixes both: push defaulting to tickets data, and diff showing empty locals. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/platform-events.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index f8c1539cbe5e..d8841b2d8392 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -33,7 +33,10 @@ function entityFilename(e: Record): string { function findLocalFile(dir: string, id: number): string | undefined { if (!existsSync(dir)) return undefined const prefix = `${id}-` - const files = require("fs").readdirSync(dir).filter((f: string) => f.startsWith(prefix) && f.endsWith(".json")) + // Exclude tickets files — those are managed by tickets-pull/push + const files = require("fs").readdirSync(dir).filter((f: string) => + f.startsWith(prefix) && f.endsWith(".json") && !f.includes("tickets") + ) return files.length > 0 ? join(dir, files[0]) : undefined } From 0f63d1b566bc4cce17af44a248442ccb9d07acb0 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 10:10:24 -0500 Subject: [PATCH 09/24] =?UTF-8?q?feat:=20events=20push=20pass-through=20?= =?UTF-8?q?=E2=80=94=20unknown=20fields=20saved=20to=20metadata=20(#58785)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of allowlisting specific fields, push now sends all fields. Known fields go directly to the API. Unknown fields (twitter, custom data, production notes) are merged into metadata so nothing is lost. Roundtrip: pull → edit → push preserves everything. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/platform-events.ts | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index d8841b2d8392..5d43d8bb5de8 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -364,17 +364,29 @@ const PushCommand = cmd({ spinner.start(`Pushing ${basename(filepath)}…`) const entity = JSON.parse(readFileSync(filepath, "utf-8")) - const payload: Record = { - title: entity.title, description: entity.description, - start_date: entity.start_date, start_time: entity.start_time, - end_date: entity.end_date, end_time: entity.end_time, - venue_name: entity.venue_name, street: entity.street, - city: entity.city, state: entity.state, zip: entity.zip, - pricing: entity.pricing, purchase_ticket_url: entity.purchase_ticket_url, - tags: entity.tags, event_type: entity.event_type, status: entity.status, - url: entity.url, photo: entity.photo, + // Pass-through: send all fields. API validates known fields, + // unknown fields are saved to metadata so nothing is lost (#58785) + const READONLY = new Set(["id", "created_at", "updated_at", "creator", "tickets", "stages", "vendors", "staff", "bloq"]) + const payload: Record = {} + const extraMetadata: Record = {} + const KNOWN_FIELDS = new Set([ + "title", "description", "start_date", "start_time", "end_date", "end_time", + "venue_name", "street", "city", "state", "zip", "pricing", + "purchase_ticket_url", "tags", "event_type", "status", "url", "photo", + "metadata", "profile_id", "bloq_id", + ]) + for (const [k, v] of Object.entries(entity)) { + if (READONLY.has(k) || v === undefined || v === null) continue + if (KNOWN_FIELDS.has(k)) { + payload[k] = v + } else { + extraMetadata[k] = v + } + } + // Merge extra fields into metadata so they're preserved + if (Object.keys(extraMetadata).length > 0) { + payload.metadata = { ...(entity.metadata ?? {}), ...extraMetadata } } - for (const k of Object.keys(payload)) { if (payload[k] === undefined) delete payload[k] } const res = await irisFetch(`/api/v1/events/${args.id}`, { method: "PUT", body: JSON.stringify(payload) }) const ok = await handleApiError(res, "Push event") From 5ca3b923213548b8c781b8ebde47ccb57e329db0 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 10:14:44 -0500 Subject: [PATCH 10/24] feat: events push warns when fields saved to metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows which non-schema fields went to metadata after push. Transparency — user knows what's validated vs catch-all. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/platform-events.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index 5d43d8bb5de8..1543319edd1f 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -384,7 +384,8 @@ const PushCommand = cmd({ } } // Merge extra fields into metadata so they're preserved - if (Object.keys(extraMetadata).length > 0) { + const metaKeys = Object.keys(extraMetadata) + if (metaKeys.length > 0) { payload.metadata = { ...(entity.metadata ?? {}), ...extraMetadata } } @@ -397,6 +398,11 @@ const PushCommand = cmd({ printDivider() printKV("ID", args.id) printKV("From", filepath) + // Warn about fields saved to metadata (not schema-validated) + if (metaKeys.length > 0) { + console.log(` ${dim("Saved to metadata:")} ${metaKeys.join(", ")}`) + console.log(` ${dim("These fields are preserved but not schema-validated.")}`) + } printDivider() prompts.outro(dim(`iris events diff ${args.id}`)) From 3c0a4dbd0731b44b56540d97657b6f93d3b7c469 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 10:33:19 -0500 Subject: [PATCH 11/24] =?UTF-8?q?fix:=20iMessage=20search=20default=207?= =?UTF-8?q?=E2=86=9230=20days=20+=20--since=20flag=20for=20date=20range=20?= =?UTF-8?q?(#58884)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default lookback increased from 7 to 30 days. New --since flag accepts YYYY-MM-DD for absolute date control. Applies to both search and chats. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/platform-imessage.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-imessage.ts b/packages/opencode/src/cli/cmd/platform-imessage.ts index 1eb1d6327822..b5c3f3234e97 100644 --- a/packages/opencode/src/cli/cmd/platform-imessage.ts +++ b/packages/opencode/src/cli/cmd/platform-imessage.ts @@ -24,7 +24,8 @@ const ImessageSearchCommand = cmd({ builder: (yargs) => yargs .positional("query", { type: "string", demandOption: true, describe: "phone number (last 10 digits) or chat identifier" }) - .option("days", { type: "number", default: 7, describe: "search last N days" }) + .option("days", { type: "number", default: 30, describe: "search last N days" }) + .option("since", { type: "string", describe: "search from date (YYYY-MM-DD)" }) .option("limit", { type: "number", default: 50, describe: "max messages" }) .option("json", { type: "boolean", default: false }), async handler(args) { @@ -52,7 +53,10 @@ const ImessageSearchCommand = cmd({ ? `c.chat_identifier LIKE '%${digits.slice(-10)}%'` : `c.chat_identifier LIKE '%${args.query.replace(/'/g, "''")}%'` - const cutoffSeconds = args.days * 86400 + // --since takes priority over --days (#58884) + const cutoffSeconds = args.since + ? Math.max(0, Math.floor((Date.now() - new Date(String(args.since)).getTime()) / 1000)) + : (args.days as number) * 86400 const sql = ` SELECT m.rowid, @@ -135,7 +139,10 @@ const ImessageReadCommand = cmd({ ? `c.chat_identifier LIKE '%${digits.slice(-10)}%'` : `c.chat_identifier LIKE '%${args.query.replace(/'/g, "''")}%'` - const cutoffSeconds = args.days * 86400 + // --since takes priority over --days (#58884) + const cutoffSeconds = args.since + ? Math.max(0, Math.floor((Date.now() - new Date(String(args.since)).getTime()) / 1000)) + : (args.days as number) * 86400 const sql = ` SELECT m.rowid, @@ -194,7 +201,8 @@ const ImessageChatsCommand = cmd({ describe: "list recent iMessage conversations", builder: (yargs) => yargs - .option("days", { type: "number", default: 7, describe: "recent conversations in last N days" }) + .option("days", { type: "number", default: 30, describe: "recent conversations in last N days" }) + .option("since", { type: "string", describe: "from date (YYYY-MM-DD)" }) .option("limit", { type: "number", default: 50, describe: "max conversations" }) .option("json", { type: "boolean", default: false }), async handler(args) { @@ -207,7 +215,10 @@ const ImessageChatsCommand = cmd({ return } - const cutoffSeconds = args.days * 86400 + // --since takes priority over --days (#58884) + const cutoffSeconds = args.since + ? Math.max(0, Math.floor((Date.now() - new Date(String(args.since)).getTime()) / 1000)) + : (args.days as number) * 86400 const sql = ` SELECT c.chat_identifier, From a5cb1c6a9cafec52a56fbf4546c642674920d381 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 10:39:42 -0500 Subject: [PATCH 12/24] feat: iMessage resolves contact names from Leads CRM (#58888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - search: shows lead name instead of "Them" for incoming messages - chats: resolves phone numbers → lead names in conversation list - Searches leads by last 10 digits of phone number - Falls back to raw identifier if no lead found Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/platform-imessage.ts | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-imessage.ts b/packages/opencode/src/cli/cmd/platform-imessage.ts index b5c3f3234e97..4341301ed500 100644 --- a/packages/opencode/src/cli/cmd/platform-imessage.ts +++ b/packages/opencode/src/cli/cmd/platform-imessage.ts @@ -93,11 +93,26 @@ const ImessageSearchCommand = cmd({ return } + // Resolve contact name from leads (#58888) + let contactName = "Them" + try { + const { irisFetch: _fetch } = await import("./iris-api") + const searchQ = digits || String(args.query) + const leadRes = await _fetch(`/api/v1/leads?search=${encodeURIComponent(searchQ)}&per_page=1`) + if (leadRes.ok) { + const leadData = (await leadRes.json()) as any + const leads = leadData?.data ?? [] + if (Array.isArray(leads) && leads.length > 0) { + contactName = leads[0].name ?? leads[0].first_name ?? "Them" + } + } + } catch {} + // Display in chronological order (oldest first) const reversed = [...messages].reverse() printDivider() for (const msg of reversed) { - const direction = msg.from_me ? bold("You →") : bold("← Them") + const direction = msg.from_me ? bold("You →") : bold(`← ${contactName}`) const dateStr = dim(msg.date) console.log(` ${dateStr} ${direction} ${msg.text}`) } @@ -252,9 +267,29 @@ const ImessageChatsCommand = cmd({ return } + // Resolve phone numbers → lead names in bulk (#58888) + const phoneMap = new Map() + try { + const { irisFetch: _fetch } = await import("./iris-api") + const phones = chats.filter(c => /^\+?\d{10,}$/.test(c.identifier.replace(/[^+\d]/g, ""))) + for (const c of phones.slice(0, 10)) { + const digits = c.identifier.replace(/[^0-9]/g, "").slice(-10) + const r = await _fetch(`/api/v1/leads?search=${digits}&per_page=1`) + if (r.ok) { + const d = (await r.json()) as any + const leads = d?.data ?? [] + if (Array.isArray(leads) && leads.length > 0) { + phoneMap.set(c.identifier, leads[0].name ?? leads[0].first_name ?? c.identifier) + } + } + } + } catch {} + printDivider() for (const chat of chats) { - console.log(` ${bold(chat.identifier)} ${dim(`${chat.message_count} msgs`)} ${dim(chat.last_message)}`) + const name = phoneMap.get(chat.identifier) + const label = name ? `${bold(name)} ${dim(chat.identifier)}` : bold(chat.identifier) + console.log(` ${label} ${dim(`${chat.message_count} msgs`)} ${dim(chat.last_message)}`) } printDivider() prompts.outro(`${success("✓")} ${chats.length} conversation${chats.length === 1 ? "" : "s"}`) From 2cc18b6686eae4576b9046e13ee53dfec4258309 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 10:45:20 -0500 Subject: [PATCH 13/24] refactor: centralize iMessage + contact resolution into shared libs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New shared libraries: - src/cli/lib/imessage.ts — isAvailable(), query(), searchByHandle(), listChats(), normalizeHandle(). Single source of truth for Messages.app SQLite. - src/cli/lib/contacts.ts — resolveContactName(), resolveContactNames(), clearContactCache(). Per-session cached lead name resolution. Refactored consumers: - platform-doctor.ts: inline sqlite3 → isAvailable() - platform-atlas-comms.ts: inline sqlite3 + MESSAGES_DB → searchByHandle() - platform-leads.ts: inline sqlite3 health check → isAvailable() 139/139 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/cli/cmd/platform-atlas-comms.ts | 37 ++--- .../opencode/src/cli/cmd/platform-doctor.ts | 15 +- .../opencode/src/cli/cmd/platform-leads.ts | 16 +- packages/opencode/src/cli/lib/contacts.ts | 71 +++++++++ packages/opencode/src/cli/lib/imessage.ts | 143 ++++++++++++++++++ 5 files changed, 240 insertions(+), 42 deletions(-) create mode 100644 packages/opencode/src/cli/lib/contacts.ts create mode 100644 packages/opencode/src/cli/lib/imessage.ts diff --git a/packages/opencode/src/cli/cmd/platform-atlas-comms.ts b/packages/opencode/src/cli/cmd/platform-atlas-comms.ts index caf3ff30f5ec..f7aa35b88d6e 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-comms.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-comms.ts @@ -2,8 +2,6 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, dim, bold, success, highlight } from "./iris-api" -import { execSync } from "child_process" -import { homedir } from "os" // ============================================================================ // Atlas Comms CLI — Unified cross-channel lead communications log @@ -53,12 +51,12 @@ async function resolveLead(idOrQuery: string): Promise<{ id: number; lead: any } return { id: leadId, lead: data?.data ?? data } } -// ── iMessage ingestion (local SQLite) ── +// ── iMessage ingestion (via shared lib) ── function ingestImessage(lead: any): any[] { - const MESSAGES_DB = `${homedir()}/Library/Messages/chat.db` + const { searchByHandle, normalizeHandle } = require("../lib/imessage") const identifiers: string[] = [] - if (lead.phone) identifiers.push(lead.phone.replace(/\D/g, "").slice(-10)) + if (lead.phone) identifiers.push(normalizeHandle(lead.phone)) if (lead.email) identifiers.push(lead.email) if (lead.instagram) identifiers.push(lead.instagram.replace("@", "")) @@ -67,28 +65,15 @@ function ingestImessage(lead: any): any[] { const items: any[] = [] for (const ident of identifiers) { try { - const sql = `SELECT - m.rowid, m.text, m.is_from_me, m.date, - datetime(m.date/1000000000 + strftime('%s','2001-01-01'), 'unixepoch', 'localtime') as sent_dt, - c.chat_identifier - FROM message m - JOIN chat_message_join cmj ON m.rowid = cmj.message_id - JOIN chat c ON cmj.chat_id = c.rowid - WHERE c.chat_identifier LIKE '%${ident}%' - AND m.text IS NOT NULL AND m.text != '' - ORDER BY m.date DESC LIMIT 100` - const raw = execSync(`sqlite3 "${MESSAGES_DB}" "${sql}"`, { encoding: "utf-8", timeout: 10000 }).trim() - if (!raw) continue - for (const line of raw.split("\n")) { - const parts = line.split("|") - if (parts.length < 5) continue + const messages = searchByHandle(ident, 90, 100) + for (const m of messages) { items.push({ - direction: parts[2] === "1" ? "outbound" : "inbound", - from_identifier: parts[2] === "1" ? "me" : (parts[5] || ident), - body: parts[1], - sent_at: parts[4], - external_message_id: `imessage_${parts[0]}`, - metadata: { chat_identifier: parts[5] || ident }, + direction: m.from_me ? "outbound" : "inbound", + from_identifier: m.from_me ? "me" : (m.chat_identifier || ident), + body: m.text, + sent_at: m.date, + external_message_id: `imessage_${m.id}`, + metadata: { chat_identifier: m.chat_identifier || ident }, }) } } catch { /* SQLite access may fail — skip silently */ } diff --git a/packages/opencode/src/cli/cmd/platform-doctor.ts b/packages/opencode/src/cli/cmd/platform-doctor.ts index 30e6ac25835f..908922ea0c74 100644 --- a/packages/opencode/src/cli/cmd/platform-doctor.ts +++ b/packages/opencode/src/cli/cmd/platform-doctor.ts @@ -128,12 +128,15 @@ export const PlatformDoctorCommand = cmd({ // ── 5. macOS Permissions ── sp.start("Checking macOS permissions…") // Full Disk Access (needed for iMessage SQLite) - try { - const db = `${homedir()}/Library/Messages/chat.db` - execSync(`sqlite3 "${db}" "SELECT 1 FROM message LIMIT 1" 2>&1`, { encoding: "utf-8", timeout: 3000 }) - allResults.push({ name: "Full Disk Access", ok: true, detail: "Messages.app readable" }) - } catch { - allResults.push({ name: "Full Disk Access", ok: false, detail: "cannot read Messages.app", hint: "System Settings → Privacy → Full Disk Access" }) + { + const { isAvailable } = await import("../lib/imessage") + const ok = isAvailable() + allResults.push({ + name: "Full Disk Access", + ok, + detail: ok ? "Messages.app readable" : "cannot read Messages.app", + hint: ok ? undefined : "System Settings → Privacy → Full Disk Access", + }) } // Contacts access (needed for address book matching) diff --git a/packages/opencode/src/cli/cmd/platform-leads.ts b/packages/opencode/src/cli/cmd/platform-leads.ts index d967e76e604f..5918a30ffdfc 100644 --- a/packages/opencode/src/cli/cmd/platform-leads.ts +++ b/packages/opencode/src/cli/cmd/platform-leads.ts @@ -1325,17 +1325,13 @@ export async function runChannelHealthChecks(): Promise { // iMessage — verify macOS Messages.app SQLite access (async (): Promise => { try { - const { execSync } = await import("child_process") - const { homedir } = await import("os") - const db = `${homedir()}/Library/Messages/chat.db` - execSync(`sqlite3 "${db}" "SELECT count(*) FROM message LIMIT 1"`, { encoding: "utf-8", timeout: 3000 }) - return { name: "iMessage", ok: true, status: "verified" } - } catch (e: any) { - const msg = e?.message ?? "" - if (msg.includes("not authorized") || msg.includes("permission denied")) { - return { name: "iMessage", ok: false, status: "no_permission", error: "Full Disk Access required", hint: "System Settings → Privacy → Full Disk Access → enable terminal" } + const { isAvailable } = await import("../lib/imessage") + if (isAvailable()) { + return { name: "iMessage", ok: true, status: "verified" } } - return { name: "iMessage", ok: false, status: "error", error: "SQLite access failed", hint: "check macOS Messages.app" } + return { name: "iMessage", ok: false, status: "no_permission", error: "Full Disk Access required", hint: "System Settings → Privacy → Full Disk Access → enable terminal" } + } catch { + return { name: "iMessage", ok: false, status: "error", error: "check failed", hint: "check macOS Messages.app" } } })(), diff --git a/packages/opencode/src/cli/lib/contacts.ts b/packages/opencode/src/cli/lib/contacts.ts new file mode 100644 index 000000000000..523a801eb47f --- /dev/null +++ b/packages/opencode/src/cli/lib/contacts.ts @@ -0,0 +1,71 @@ +/** + * Contact resolution utility — single source of truth for phone/email → lead name. + * + * Used by: platform-imessage.ts, platform-atlas-comms.ts, platform-leads.ts + * Cache is per-session (in-memory Map), intentionally not persisted. + */ + +import { irisFetch } from "../cmd/iris-api" + +const cache = new Map() + +/** + * Normalize a phone/email/handle for search. + */ +function normalizeForSearch(identifier: string): string { + const digits = identifier.replace(/[^0-9]/g, "") + return digits.length >= 10 ? digits.slice(-10) : identifier +} + +/** + * Resolve a phone number, email, or handle to a lead name. + * Returns null if no match found. Results are cached per-session. + */ +export async function resolveContactName(identifier: string): Promise { + if (!identifier) return null + if (cache.has(identifier)) return cache.get(identifier) ?? null + + const search = normalizeForSearch(identifier) + + try { + const res = await irisFetch(`/api/v1/leads?search=${encodeURIComponent(search)}&per_page=1`) + if (res.ok) { + const data = (await res.json()) as any + const leads = data?.data?.data ?? data?.data ?? [] + if (Array.isArray(leads) && leads.length > 0) { + const name = leads[0].name ?? leads[0].first_name ?? null + cache.set(identifier, name) + return name + } + } + } catch {} + + cache.set(identifier, null) + return null +} + +/** + * Batch-resolve multiple identifiers to names. + * Resolves up to 10 in parallel, returns a Map of identifier → name. + */ +export async function resolveContactNames(identifiers: string[]): Promise> { + const result = new Map() + const unique = [...new Set(identifiers.filter(Boolean))] + const batch = unique.slice(0, 10) + + await Promise.allSettled( + batch.map(async (id) => { + const name = await resolveContactName(id) + if (name) result.set(id, name) + }) + ) + + return result +} + +/** + * Clear the cache (useful for testing or long-running sessions). + */ +export function clearContactCache(): void { + cache.clear() +} diff --git a/packages/opencode/src/cli/lib/imessage.ts b/packages/opencode/src/cli/lib/imessage.ts new file mode 100644 index 000000000000..d9cebab93acb --- /dev/null +++ b/packages/opencode/src/cli/lib/imessage.ts @@ -0,0 +1,143 @@ +/** + * iMessage SQLite utility — single source of truth for Messages.app access. + * + * Used by: platform-imessage.ts, platform-atlas-comms.ts, platform-customer.ts, platform-doctor.ts + */ + +import { execSync } from "child_process" +import { existsSync } from "fs" +import { homedir } from "os" + +const MESSAGES_DB = `${homedir()}/Library/Messages/chat.db` + +// ── Types ── + +export interface Message { + id: string + date: string + from_me: boolean + text: string + chat_identifier?: string +} + +export interface Chat { + identifier: string + message_count: number + last_message: string +} + +// ── Core ── + +/** + * Check if iMessage SQLite is accessible (macOS only + Full Disk Access). + */ +export function isAvailable(): boolean { + if (process.platform !== "darwin") return false + if (!existsSync(MESSAGES_DB)) return false + try { + execSync(`sqlite3 "${MESSAGES_DB}" "SELECT 1 FROM message LIMIT 1"`, { + encoding: "utf-8", + timeout: 3000, + }) + return true + } catch { + return false + } +} + +/** + * Run a raw SQL query against the Messages database. + * Escapes double quotes in the SQL string. + */ +export function query(sql: string): string { + const escaped = sql.replace(/"/g, '\\"') + return execSync(`sqlite3 "${MESSAGES_DB}" "${escaped}"`, { + encoding: "utf-8", + timeout: 10000, + }).trim() +} + +/** + * Normalize a phone/email/handle to a search-friendly format. + * Phones: strip non-digits, take last 10 digits. + */ +export function normalizeHandle(handle: string): string { + const digits = handle.replace(/[^0-9]/g, "") + if (digits.length >= 10) return digits.slice(-10) + return handle +} + +/** + * Search messages by phone number, email, or chat identifier. + */ +export function searchByHandle(handle: string, days = 30, limit = 50): Message[] { + const search = normalizeHandle(handle) + const cutoffSeconds = days * 86400 + + const sql = `SELECT + m.rowid, m.text, m.is_from_me, m.date, + datetime(m.date/1000000000 + strftime('%s','2001-01-01'), 'unixepoch', 'localtime') as sent_dt, + c.chat_identifier + FROM message m + JOIN chat_message_join cmj ON m.rowid = cmj.message_id + JOIN chat c ON cmj.chat_id = c.rowid + WHERE c.chat_identifier LIKE '%${search}%' + AND m.text IS NOT NULL AND m.text != '' + AND m.date > (strftime('%s','now') - ${cutoffSeconds} - strftime('%s','2001-01-01')) * 1000000000 + ORDER BY m.date DESC LIMIT ${limit}` + + try { + const raw = query(sql) + if (!raw) return [] + + return raw.split("\n").map((line) => { + const parts = line.split("|") + if (parts.length < 5) return null + return { + id: parts[0], + text: parts[1], + from_me: parts[2] === "1", + date: parts[4], + chat_identifier: parts[5] || search, + } satisfies Message + }).filter(Boolean) as Message[] + } catch { + return [] + } +} + +/** + * List recent conversations with message counts. + */ +export function listChats(days = 30, limit = 50): Chat[] { + const cutoffSeconds = days * 86400 + + const sql = `SELECT + c.chat_identifier, + COUNT(m.rowid) as msg_count, + MAX(datetime(m.date/1000000000 + strftime('%s','2001-01-01'), 'unixepoch', 'localtime')) as last_msg + FROM chat c + JOIN chat_message_join cmj ON c.rowid = cmj.chat_id + JOIN message m ON cmj.message_id = m.rowid + WHERE m.date > (strftime('%s','now') - ${cutoffSeconds} - strftime('%s','2001-01-01')) * 1000000000 + GROUP BY c.chat_identifier + ORDER BY last_msg DESC + LIMIT ${limit}` + + try { + const raw = query(sql) + if (!raw) return [] + + return raw.split("\n").map((line) => { + const parts = line.split("|") + if (parts.length < 3) return null + return { + identifier: parts[0], + message_count: parseInt(parts[1], 10), + last_message: parts[2], + } satisfies Chat + }).filter(Boolean) as Chat[] + } catch { + return [] + } +} From 0b17e4fad558244721291ad2c84b8329fd65aa1e Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 10:49:34 -0500 Subject: [PATCH 14/24] =?UTF-8?q?refactor:=20platform-imessage=20+=20platf?= =?UTF-8?q?orm-customer=20=E2=86=92=20shared=20iMessage/contacts=20libs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit platform-imessage.ts: - Removed MESSAGES_DB constant + queryMessages() function - Uses isAvailable(), query(), normalizeHandle() from lib/imessage - Uses resolveContactName/Names() from lib/contacts (was 20 lines inline) - 3 platform checks simplified to isAvailable() platform-customer.ts: - queryMessagesDb() now delegates to lib/imessage query() - Removed direct execSync + hardcoded DB path All 5 files from REFACTOR_IMESSAGE_CONTACTS.md now use shared libs. 139/139 regression tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/platform-customer.ts | 5 +- .../opencode/src/cli/cmd/platform-imessage.ts | 71 ++++--------------- 2 files changed, 17 insertions(+), 59 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-customer.ts b/packages/opencode/src/cli/cmd/platform-customer.ts index 5d7fbc6cf8b6..e460222daf50 100644 --- a/packages/opencode/src/cli/cmd/platform-customer.ts +++ b/packages/opencode/src/cli/cmd/platform-customer.ts @@ -398,10 +398,9 @@ async function searchMail(query: string, days: number): Promise { } function queryMessagesDb(sql: string): string { - const { execSync } = require("child_process") as typeof import("child_process") - const db = `${process.env.HOME}/Library/Messages/chat.db` try { - return execSync(`sqlite3 "${db}" "${sql.replace(/"/g, '\\"')}"`, { encoding: "utf-8", timeout: 5000 }).trim() + const { query } = require("../lib/imessage") + return query(sql) } catch { return "" } } diff --git a/packages/opencode/src/cli/cmd/platform-imessage.ts b/packages/opencode/src/cli/cmd/platform-imessage.ts index 4341301ed500..96e83304dbb0 100644 --- a/packages/opencode/src/cli/cmd/platform-imessage.ts +++ b/packages/opencode/src/cli/cmd/platform-imessage.ts @@ -3,19 +3,8 @@ import * as prompts from "@clack/prompts" import { UI } from "../ui" import { printDivider, dim, bold, success } from "./iris-api" import { execSync } from "child_process" -import { existsSync } from "fs" - -// macOS iMessage integration — reads directly from ~/Library/Messages/chat.db (SQLite) -// No bridge dependency needed — just macOS + Full Disk Access permission - -const MESSAGES_DB = `${process.env.HOME}/Library/Messages/chat.db` - -function queryMessages(sql: string): string { - return execSync(`sqlite3 "${MESSAGES_DB}" "${sql.replace(/"/g, '\\"')}"`, { - encoding: "utf-8", - timeout: 10000, - }).trim() -} +import { isAvailable, query as queryMessages, normalizeHandle } from "../lib/imessage" +import { resolveContactName, resolveContactNames } from "../lib/contacts" const ImessageSearchCommand = cmd({ command: "search ", @@ -32,14 +21,8 @@ const ImessageSearchCommand = cmd({ UI.empty() prompts.intro(`◈ iMessage Search — "${args.query}"`) - if (process.platform !== "darwin") { - prompts.log.error("iMessage is only available on macOS") - prompts.outro("Done") - return - } - - if (!existsSync(MESSAGES_DB)) { - prompts.log.error(`Messages database not found at ${MESSAGES_DB}. Grant Full Disk Access in System Settings.`) + if (!isAvailable()) { + prompts.log.error("iMessage not available. Requires macOS + Full Disk Access in System Settings.") prompts.outro("Done") return } @@ -47,10 +30,11 @@ const ImessageSearchCommand = cmd({ // Normalize phone number — strip everything except digits const digits = args.query.replace(/\D/g, "") const isPhone = digits.length >= 7 + const normalized = isPhone ? normalizeHandle(args.query) : args.query // Build WHERE clause — match chat_identifier by phone digits or name const whereClause = isPhone - ? `c.chat_identifier LIKE '%${digits.slice(-10)}%'` + ? `c.chat_identifier LIKE '%${normalized}%'` : `c.chat_identifier LIKE '%${args.query.replace(/'/g, "''")}%'` // --since takes priority over --days (#58884) @@ -94,19 +78,7 @@ const ImessageSearchCommand = cmd({ } // Resolve contact name from leads (#58888) - let contactName = "Them" - try { - const { irisFetch: _fetch } = await import("./iris-api") - const searchQ = digits || String(args.query) - const leadRes = await _fetch(`/api/v1/leads?search=${encodeURIComponent(searchQ)}&per_page=1`) - if (leadRes.ok) { - const leadData = (await leadRes.json()) as any - const leads = leadData?.data ?? [] - if (Array.isArray(leads) && leads.length > 0) { - contactName = leads[0].name ?? leads[0].first_name ?? "Them" - } - } - } catch {} + const contactName = await resolveContactName(digits || String(args.query)) ?? "Them" // Display in chronological order (oldest first) const reversed = [...messages].reverse() @@ -142,16 +114,17 @@ const ImessageReadCommand = cmd({ UI.empty() prompts.intro(`◈ iMessage Read — "${args.query}"`) - if (process.platform !== "darwin" || !existsSync(MESSAGES_DB)) { - prompts.log.error("iMessage database not available") + if (!isAvailable()) { + prompts.log.error("iMessage not available. Requires macOS + Full Disk Access.") prompts.outro("Done") return } const digits = args.query.replace(/\D/g, "") const isPhone = digits.length >= 7 + const normalized = isPhone ? normalizeHandle(args.query) : args.query const whereClause = isPhone - ? `c.chat_identifier LIKE '%${digits.slice(-10)}%'` + ? `c.chat_identifier LIKE '%${normalized}%'` : `c.chat_identifier LIKE '%${args.query.replace(/'/g, "''")}%'` // --since takes priority over --days (#58884) @@ -224,8 +197,8 @@ const ImessageChatsCommand = cmd({ UI.empty() prompts.intro("◈ Recent iMessage Chats") - if (process.platform !== "darwin" || !existsSync(MESSAGES_DB)) { - prompts.log.error("iMessage database not available") + if (!isAvailable()) { + prompts.log.error("iMessage not available. Requires macOS + Full Disk Access.") prompts.outro("Done") return } @@ -268,22 +241,8 @@ const ImessageChatsCommand = cmd({ } // Resolve phone numbers → lead names in bulk (#58888) - const phoneMap = new Map() - try { - const { irisFetch: _fetch } = await import("./iris-api") - const phones = chats.filter(c => /^\+?\d{10,}$/.test(c.identifier.replace(/[^+\d]/g, ""))) - for (const c of phones.slice(0, 10)) { - const digits = c.identifier.replace(/[^0-9]/g, "").slice(-10) - const r = await _fetch(`/api/v1/leads?search=${digits}&per_page=1`) - if (r.ok) { - const d = (await r.json()) as any - const leads = d?.data ?? [] - if (Array.isArray(leads) && leads.length > 0) { - phoneMap.set(c.identifier, leads[0].name ?? leads[0].first_name ?? c.identifier) - } - } - } - } catch {} + const phones = chats.filter(c => /^\+?\d{10,}$/.test(c.identifier.replace(/[^+\d]/g, ""))) + const phoneMap = await resolveContactNames(phones.map(c => c.identifier)) printDivider() for (const chat of chats) { From 141c1fd0b509d6f8431040d2e710099354fbe593 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 10:55:23 -0500 Subject: [PATCH 15/24] fix: imessage search resolves lead names to phone/email (#58890/#58891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When searching by name (e.g. "Lisa Martinez"), the CLI now: 1. Searches leads API for matching contacts 2. Finds the first lead with a phone number → searches iMessage by that phone 3. Falls back to email if no phone (iMessage uses Apple ID = email) 4. Displays resolved contact name in results Also fixed contacts.ts normalizeForSearch to strip +1 country code prefix from E.164 format (+14695633672 → 4695633672) for consistent matching. 139/139 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/platform-imessage.ts | 35 +++++++++++++++++-- packages/opencode/src/cli/lib/contacts.ts | 6 +++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-imessage.ts b/packages/opencode/src/cli/cmd/platform-imessage.ts index 96e83304dbb0..233edd17fa63 100644 --- a/packages/opencode/src/cli/cmd/platform-imessage.ts +++ b/packages/opencode/src/cli/cmd/platform-imessage.ts @@ -29,10 +29,39 @@ const ImessageSearchCommand = cmd({ // Normalize phone number — strip everything except digits const digits = args.query.replace(/\D/g, "") - const isPhone = digits.length >= 7 - const normalized = isPhone ? normalizeHandle(args.query) : args.query + let isPhone = digits.length >= 7 + let normalized = isPhone ? normalizeHandle(args.query) : args.query + + // If not a phone number, try to resolve as lead name → phone or email (#58890) + if (!isPhone) { + try { + const { irisFetch: _fetch } = await import("./iris-api") + const leadRes = await _fetch(`/api/v1/leads?search=${encodeURIComponent(args.query)}&per_page=5`) + if (leadRes.ok) { + const leadData = (await leadRes.json()) as any + const leads = leadData?.data?.data ?? leadData?.data ?? [] + if (Array.isArray(leads)) { + // Try phone first, then email as iMessage handle + const withPhone = leads.find((l: any) => l.phone) + const withEmail = leads.find((l: any) => l.email) + if (withPhone) { + const resolvedDigits = withPhone.phone.replace(/\D/g, "") + if (resolvedDigits.length >= 7) { + normalized = normalizeHandle(withPhone.phone) + isPhone = true + prompts.log.info(`Resolved "${args.query}" → ${withPhone.name || "?"} (${withPhone.phone})`) + } + } else if (withEmail) { + // iMessage can use email as Apple ID handle + normalized = withEmail.email + prompts.log.info(`Resolved "${args.query}" → ${withEmail.name || "?"} (${withEmail.email})`) + } + } + } + } catch {} + } - // Build WHERE clause — match chat_identifier by phone digits or name + // Build WHERE clause — match chat_identifier by phone digits or email const whereClause = isPhone ? `c.chat_identifier LIKE '%${normalized}%'` : `c.chat_identifier LIKE '%${args.query.replace(/'/g, "''")}%'` diff --git a/packages/opencode/src/cli/lib/contacts.ts b/packages/opencode/src/cli/lib/contacts.ts index 523a801eb47f..df061fb514b7 100644 --- a/packages/opencode/src/cli/lib/contacts.ts +++ b/packages/opencode/src/cli/lib/contacts.ts @@ -11,10 +11,14 @@ const cache = new Map() /** * Normalize a phone/email/handle for search. + * Handles E.164 (+14695633672), parens ((469) 563-3672), and raw digits. */ function normalizeForSearch(identifier: string): string { + // Strip + prefix and all non-digits const digits = identifier.replace(/[^0-9]/g, "") - return digits.length >= 10 ? digits.slice(-10) : identifier + // If it looks like a phone (7+ digits), take last 10 (strip country code) + if (digits.length >= 7) return digits.slice(-10) + return identifier } /** From b943f9fbd0f5f6e2b16ab666d50af74b6e92feef Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 11:00:50 -0500 Subject: [PATCH 16/24] =?UTF-8?q?feat:=20iris=20events=20production=20?= =?UTF-8?q?=E2=80=94=20runsheet,=20checklist,=20budget,=20overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New subcommand group for event production management: - iris events production overview — full production dashboard - iris events production runsheet — timeline with NOW/NEXT, --add, --done - iris events production checklist — todo list with completion %, --add, --done - iris events production budget — income/expenses/margin, --add-income, --add-expense All data stored in metadata.production (no new tables). Runsheet auto-populates from stage set_times + production_timeline if empty. 139/139 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/cli/cmd/platform-events-production.ts | 516 ++++++++++++++++++ .../opencode/src/cli/cmd/platform-events.ts | 2 + 2 files changed, 518 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/platform-events-production.ts diff --git a/packages/opencode/src/cli/cmd/platform-events-production.ts b/packages/opencode/src/cli/cmd/platform-events-production.ts new file mode 100644 index 000000000000..ec2fdf0210da --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-events-production.ts @@ -0,0 +1,516 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" + +// ============================================================================ +// Event Production CLI — runsheet, checklist, budget, overview +// All data in metadata.production (no new tables) +// ============================================================================ + +async function loadEvent(eventId: number): Promise { + const res = await irisFetch(`/api/v1/events/${eventId}`) + if (!res.ok) return null + return ((await res.json()) as any)?.data ?? null +} + +async function loadSubResources(eventId: number) { + const [stagesRes, ticketsRes, vendorsRes] = await Promise.all([ + irisFetch(`/api/v1/events/${eventId}/stages`).catch(() => null), + irisFetch(`/api/v1/events/${eventId}/tickets`).catch(() => null), + irisFetch(`/api/v1/events/${eventId}/vendors`).catch(() => null), + ]) + return { + stages: stagesRes?.ok ? ((await stagesRes.json()) as any)?.data ?? [] : [], + tickets: ticketsRes?.ok ? ((await ticketsRes.json()) as any)?.data ?? [] : [], + vendors: vendorsRes?.ok ? ((await vendorsRes.json()) as any)?.data ?? [] : [], + } +} + +function to12h(t: string): string { + if (!t) return "" + const [h, m] = t.split(":").map(Number) + const ampm = h >= 12 ? "PM" : "AM" + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h + return `${h12}:${String(m).padStart(2, "0")} ${ampm}` +} + +function getProduction(event: any): any { + return event?.metadata?.production ?? {} +} + +// Load local event JSON (for metadata editing) +async function loadLocalEvent(eventId: number): Promise<{ data: any; path: string } | null> { + const { existsSync, readFileSync, readdirSync } = await import("fs") + const { join } = await import("path") + const { homedir } = await import("os") + const dir = join(homedir(), ".iris", "events") + if (!existsSync(dir)) return null + const files = readdirSync(dir).filter((f: string) => f.startsWith(`${eventId}-`) && f.endsWith(".json") && !f.includes("tickets")) + if (files.length === 0) return null + const path = join(dir, files[0]) + return { data: JSON.parse(readFileSync(path, "utf8")), path } +} + +async function saveLocalEvent(path: string, data: any) { + const { writeFileSync } = await import("fs") + writeFileSync(path, JSON.stringify(data, null, 2)) +} + +// ── Overview ── + +const OverviewCmd = cmd({ + command: "overview", + aliases: ["status", "dashboard"], + describe: "full production dashboard — readiness, runsheet, tickets, staff, budget", + builder: (y) => y, + async handler(args: any) { + const eventId = args._parentEventId + UI.empty() + prompts.intro(`◈ Production: Event #${eventId}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const sp = prompts.spinner() + sp.start("Loading…") + + const event = await loadEvent(eventId) + if (!event) { sp.stop("Event not found", 1); prompts.outro("Done"); return } + const { stages, tickets, vendors } = await loadSubResources(eventId) + const prod = getProduction(event) + + sp.stop(bold(event.title)) + + // Header + printDivider() + printKV("Date", `${event.start_date} ${to12h(event.start_time)} – ${to12h(event.end_time)}`) + printKV("Venue", event.venue_name || dim("not set")) + + // Runsheet + const runsheet: any[] = prod.runsheet ?? [] + // Also build from stage set times if no runsheet + let timeline = runsheet + if (timeline.length === 0) { + for (const s of stages) { + for (const st of (s.set_times ?? s.event_stage_set_times ?? [])) { + timeline.push({ time: st.start_time, title: st.title, status: "pending", stage: s.title }) + } + } + // Add production timeline from metadata + for (const pt of (event.metadata?.production_timeline ?? [])) { + timeline.push({ time: pt.time, title: pt.task, status: "pending", isProd: true }) + } + timeline.sort((a: any, b: any) => (a.time || "").localeCompare(b.time || "")) + } + + if (timeline.length > 0) { + console.log() + console.log(` ${bold("Runsheet")} ${dim(`(${timeline.length} items)`)}`) + const now = new Date() + const nowTime = now.getHours().toString().padStart(2, "0") + ":" + now.getMinutes().toString().padStart(2, "0") + for (const item of timeline) { + const isPast = item.time < nowTime + const icon = item.status === "done" ? success("✓") : isPast ? dim("·") : "○" + const timeStr = dim(to12h(item.time).padEnd(9)) + const title = item.isProd ? dim(item.title) : item.title + console.log(` ${icon} ${timeStr} ${title}`) + } + } + + // Tickets + console.log() + console.log(` ${bold("Tickets")} ${dim(`(${tickets.length} tiers)`)}`) + let totalRevenue = 0 + for (const t of tickets) { + const sold = t.quantity_sold ?? 0 + const total = t.quantity_total + const price = parseFloat(t.price || "0") + totalRevenue += sold * price + const soldStr = total ? `${sold}/${total}` : `${sold}` + console.log(` ${t.title}: ${soldStr} sold ${dim(`($${price})`)}`) + } + printKV(" Ticket Revenue", `$${totalRevenue.toFixed(2)}`) + + // Vendors + console.log() + console.log(` ${bold("Vendors")} ${dim(`(${vendors.length})`)}`) + if (vendors.length === 0) { + console.log(` ${dim("none — iris events vendor-create " + eventId)}`) + } else { + for (const v of vendors) { + console.log(` ${v.title}${v.subtitle ? dim(` — ${v.subtitle}`) : ""}`) + } + } + + // Stages + console.log() + console.log(` ${bold("Stages")} ${dim(`(${stages.length})`)}`) + for (const s of stages) { + const setTimes = s.set_times ?? s.event_stage_set_times ?? [] + console.log(` ${highlight(s.title)} ${dim(`(${setTimes.length} acts)`)}`) + } + + // Checklist + const checklist: any[] = prod.checklist ?? [] + if (checklist.length > 0) { + const done = checklist.filter((c: any) => c.done).length + const pct = Math.round((done / checklist.length) * 100) + console.log() + console.log(` ${bold("Checklist")} ${dim(`${done}/${checklist.length} (${pct}%)`)}`) + for (const item of checklist) { + const icon = item.done ? success("✓") : `${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL}` + console.log(` ${icon} ${item.item}`) + } + } + + // Budget + const budget = prod.budget + if (budget) { + const income = (budget.income ?? []).reduce((s: number, i: any) => s + (i.amount || 0), 0) + const expenses = (budget.expenses ?? []).reduce((s: number, i: any) => s + (i.amount || 0), 0) + console.log() + console.log(` ${bold("Budget")}`) + printKV(" Income", `$${income}`) + printKV(" Expenses", `$${expenses}`) + printKV(" Margin", income - expenses >= 0 ? success(`$${income - expenses}`) : `${UI.Style.TEXT_DANGER}$${income - expenses}${UI.Style.TEXT_NORMAL}`) + } + + printDivider() + prompts.outro("Done") + }, +}) + +// ── Runsheet ── + +const RunsheetCmd = cmd({ + command: "runsheet", + aliases: ["timeline", "schedule"], + describe: "show/edit the run-of-show timeline", + builder: (y) => + y + .option("add", { type: "string", describe: 'add item: "18:30 Sound Check 30min"' }) + .option("done", { type: "number", describe: "mark item # as done" }) + .option("json", { type: "boolean", default: false }), + async handler(args: any) { + const eventId = args._parentEventId + UI.empty() + prompts.intro(`◈ Runsheet — Event #${eventId}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const local = await loadLocalEvent(eventId) + if (!local) { + prompts.log.error(`No local event file. Run: iris events pull ${eventId}`) + prompts.outro("Done") + return + } + + const prod = local.data.metadata?.production ?? {} + let runsheet: any[] = prod.runsheet ?? [] + + // If no runsheet, build from stages + production_timeline + if (runsheet.length === 0 && !args.add) { + const stages = local.data.stages ?? local.data.event_stages ?? [] + for (const s of stages) { + for (const st of (s.set_times ?? s.event_stage_set_times ?? [])) { + runsheet.push({ time: st.start_time, end: st.end_time, title: st.title, status: "pending", stage: s.title }) + } + } + for (const pt of (local.data.metadata?.production_timeline ?? [])) { + runsheet.push({ time: pt.time, title: pt.task, status: "pending", isProd: true }) + } + runsheet.sort((a: any, b: any) => (a.time || "").localeCompare(b.time || "")) + } + + // --add + if (args.add) { + const match = String(args.add).match(/^(\d{1,2}:\d{2})\s+(.+?)(?:\s+(\d+)min)?$/) + if (!match) { + prompts.log.error('Format: "HH:MM Title [Nmin]" — e.g. "18:30 Sound Check 30min"') + prompts.outro("Done") + return + } + const [, time, title, dur] = match + const item: any = { time, title, status: "pending" } + if (dur) item.duration_min = parseInt(dur) + runsheet.push(item) + runsheet.sort((a: any, b: any) => (a.time || "").localeCompare(b.time || "")) + + if (!local.data.metadata) local.data.metadata = {} + if (!local.data.metadata.production) local.data.metadata.production = {} + local.data.metadata.production.runsheet = runsheet + await saveLocalEvent(local.path, local.data) + prompts.log.success(`Added: ${to12h(time)} ${title}`) + prompts.log.info(dim(`Push with: iris events push ${eventId}`)) + prompts.outro("Done") + return + } + + // --done + if (args.done != null) { + const idx = args.done - 1 + if (idx < 0 || idx >= runsheet.length) { + prompts.log.error(`Item #${args.done} not found (have ${runsheet.length} items)`) + prompts.outro("Done") + return + } + runsheet[idx].status = "done" + if (!local.data.metadata) local.data.metadata = {} + if (!local.data.metadata.production) local.data.metadata.production = {} + local.data.metadata.production.runsheet = runsheet + await saveLocalEvent(local.path, local.data) + prompts.log.success(`Marked done: ${runsheet[idx].title}`) + prompts.outro("Done") + return + } + + // Display + if (args.json) { + console.log(JSON.stringify(runsheet, null, 2)) + prompts.outro("Done") + return + } + + const now = new Date() + const nowTime = now.getHours().toString().padStart(2, "0") + ":" + now.getMinutes().toString().padStart(2, "0") + let nowIdx = -1 + for (let i = runsheet.length - 1; i >= 0; i--) { + if (runsheet[i].time <= nowTime) { nowIdx = i; break } + } + + printDivider() + for (let i = 0; i < runsheet.length; i++) { + const item = runsheet[i] + const isPast = i < nowIdx + const isNow = i === nowIdx + const isNext = i === nowIdx + 1 + const isDone = item.status === "done" + + let icon = "○" + if (isDone) icon = success("✓") + else if (isNow) icon = highlight("●") + else if (isPast) icon = dim("·") + + const num = dim(`${String(i + 1).padStart(2)}.`) + const time = dim(to12h(item.time || "").padEnd(9)) + const label = isNow ? bold(item.title) : isNext ? highlight(item.title) : isPast && !isDone ? dim(item.title) : item.title + const stage = item.stage && !item.isProd ? dim(` [${item.stage}]`) : "" + const nowLabel = isNow ? ` ${highlight("← NOW")}` : isNext ? ` ${dim("← NEXT")}` : "" + + console.log(` ${num} ${icon} ${time} ${label}${stage}${nowLabel}`) + } + printDivider() + prompts.log.info(dim(`Mark done: iris events production ${eventId} runsheet --done 3`)) + prompts.log.info(dim(`Add item: iris events production ${eventId} runsheet --add "18:30 Sound Check 30min"`)) + prompts.outro("Done") + }, +}) + +// ── Checklist ── + +const ChecklistCmd = cmd({ + command: "checklist", + aliases: ["todo", "tasks"], + describe: "production checklist with completion tracking", + builder: (y) => + y + .option("add", { type: "string", describe: "add checklist item" }) + .option("done", { type: "number", describe: "mark item # as done" }) + .option("undo", { type: "number", describe: "mark item # as not done" }) + .option("json", { type: "boolean", default: false }), + async handler(args: any) { + const eventId = args._parentEventId + UI.empty() + prompts.intro(`◈ Checklist — Event #${eventId}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const local = await loadLocalEvent(eventId) + if (!local) { + prompts.log.error(`No local event file. Run: iris events pull ${eventId}`) + prompts.outro("Done") + return + } + + if (!local.data.metadata) local.data.metadata = {} + if (!local.data.metadata.production) local.data.metadata.production = {} + let checklist: any[] = local.data.metadata.production.checklist ?? [] + + // --add + if (args.add) { + checklist.push({ item: String(args.add), done: false }) + local.data.metadata.production.checklist = checklist + await saveLocalEvent(local.path, local.data) + prompts.log.success(`Added: ${args.add}`) + prompts.outro("Done") + return + } + + // --done + if (args.done != null) { + const idx = args.done - 1 + if (idx < 0 || idx >= checklist.length) { + prompts.log.error(`Item #${args.done} not found`) + prompts.outro("Done") + return + } + checklist[idx].done = true + local.data.metadata.production.checklist = checklist + await saveLocalEvent(local.path, local.data) + prompts.log.success(`Done: ${checklist[idx].item}`) + prompts.outro("Done") + return + } + + // --undo + if (args.undo != null) { + const idx = args.undo - 1 + if (idx >= 0 && idx < checklist.length) { + checklist[idx].done = false + local.data.metadata.production.checklist = checklist + await saveLocalEvent(local.path, local.data) + prompts.log.info(`Undone: ${checklist[idx].item}`) + } + prompts.outro("Done") + return + } + + // Display + if (args.json) { + console.log(JSON.stringify(checklist, null, 2)) + prompts.outro("Done") + return + } + + if (checklist.length === 0) { + prompts.log.warn("No checklist items yet") + prompts.log.info(dim(`Add: iris events production ${eventId} checklist --add "Test OBS scenes"`)) + prompts.outro("Done") + return + } + + const done = checklist.filter((c: any) => c.done).length + const pct = Math.round((done / checklist.length) * 100) + const pctColor = pct >= 80 ? success : pct >= 50 ? (s: string) => `${UI.Style.TEXT_WARNING}${s}${UI.Style.TEXT_NORMAL}` : (s: string) => `${UI.Style.TEXT_DANGER}${s}${UI.Style.TEXT_NORMAL}` + + printDivider() + for (let i = 0; i < checklist.length; i++) { + const c = checklist[i] + const icon = c.done ? success("✓") : `${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL}` + const num = dim(`${String(i + 1).padStart(2)}.`) + console.log(` ${num} ${icon} ${c.done ? dim(c.item) : c.item}`) + } + printDivider() + console.log(` Completion: ${pctColor(`${pct}%`)} (${done}/${checklist.length})`) + + prompts.outro("Done") + }, +}) + +// ── Budget (quick view) ── + +const BudgetCmd = cmd({ + command: "budget", + aliases: ["pnl", "money"], + describe: "income vs expenses with margin", + builder: (y) => + y + .option("add-income", { type: "string", describe: 'add income: "sponsors 500 confirmed"' }) + .option("add-expense", { type: "string", describe: 'add expense: "PA rental 200 pending"' }) + .option("json", { type: "boolean", default: false }), + async handler(args: any) { + const eventId = args._parentEventId + UI.empty() + prompts.intro(`◈ Budget — Event #${eventId}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const local = await loadLocalEvent(eventId) + if (!local) { + prompts.log.error(`No local event file. Run: iris events pull ${eventId}`) + prompts.outro("Done") + return + } + + if (!local.data.metadata) local.data.metadata = {} + if (!local.data.metadata.production) local.data.metadata.production = {} + if (!local.data.metadata.production.budget) local.data.metadata.production.budget = { income: [], expenses: [] } + const budget = local.data.metadata.production.budget + + // --add-income + if (args["add-income"]) { + const parts = String(args["add-income"]).split(/\s+/) + const source = parts[0] + const amount = parseFloat(parts[1] || "0") + const status = parts[2] || "pending" + budget.income.push({ source, amount, status }) + await saveLocalEvent(local.path, local.data) + prompts.log.success(`Added income: ${source} $${amount} (${status})`) + prompts.outro("Done") + return + } + + // --add-expense + if (args["add-expense"]) { + const parts = String(args["add-expense"]).split(/\s+/) + const item = parts[0] + const amount = parseFloat(parts[1] || "0") + const status = parts[2] || "pending" + budget.expenses.push({ item, amount, status }) + await saveLocalEvent(local.path, local.data) + prompts.log.success(`Added expense: ${item} $${amount} (${status})`) + prompts.outro("Done") + return + } + + // Display + const income = budget.income ?? [] + const expenses = budget.expenses ?? [] + const totalIncome = income.reduce((s: number, i: any) => s + (i.amount || 0), 0) + const totalExpenses = expenses.reduce((s: number, i: any) => s + (i.amount || 0), 0) + const margin = totalIncome - totalExpenses + + if (args.json) { + console.log(JSON.stringify({ income, expenses, totalIncome, totalExpenses, margin }, null, 2)) + prompts.outro("Done") + return + } + + printDivider() + console.log(` ${bold("Income")}`) + for (const i of income) { + console.log(` ${success("+")} $${i.amount} ${i.source} ${dim(i.status || "")}`) + } + if (income.length === 0) console.log(` ${dim("none")}`) + + console.log() + console.log(` ${bold("Expenses")}`) + for (const e of expenses) { + console.log(` ${dim("-")} $${e.amount} ${e.item} ${dim(e.status || "")}`) + } + if (expenses.length === 0) console.log(` ${dim("none")}`) + + printDivider() + printKV(" Total Income", success(`$${totalIncome}`)) + printKV(" Total Expenses", `$${totalExpenses}`) + printKV(" Margin", margin >= 0 ? success(`$${margin}`) : `${UI.Style.TEXT_DANGER}-$${Math.abs(margin)}${UI.Style.TEXT_NORMAL}`) + + prompts.outro("Done") + }, +}) + +// ============================================================================ +// Root — registered as subcommand of `iris events` +// ============================================================================ + +export const ProductionCommand = cmd({ + command: "production ", + aliases: ["prod"], + describe: "event production management — runsheet, checklist, budget, overview", + builder: (y) => + y + .positional("event-id", { type: "number", demandOption: true }) + .middleware([(argv: any) => { argv._parentEventId = argv["event-id"] }]) + .command(OverviewCmd) + .command(RunsheetCmd) + .command(ChecklistCmd) + .command(BudgetCmd) + .demandCommand(1, "specify: overview, runsheet, checklist, budget"), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index 1543319edd1f..779f2c69c984 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -4,6 +4,7 @@ import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join, basename } from "path" +import { ProductionCommand } from "./platform-events-production" // ============================================================================ // Sync helpers @@ -1668,6 +1669,7 @@ export const PlatformEventsCommand = cmd({ // Production QA .command(PreflightCommand) .command(AuditCommand) + .command(ProductionCommand) .demandCommand(), async handler() {}, }) From e5500f216b6356ec302c01fc6bc7e9097daadcc1 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 11:02:55 -0500 Subject: [PATCH 17/24] =?UTF-8?q?feat:=20iris=20imessage=20contacts=20?= =?UTF-8?q?=E2=80=94=20read=20vCard=20contact=20cards=20from=20Messages=20?= =?UTF-8?q?(#58893)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parses .vcf attachments from iMessage SQLite DB. Extracts name, phones, emails, company. New command: iris imessage contacts [--days 90] [--chat ...] Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/platform-imessage.ts | 60 ++++++++++++++- packages/opencode/src/cli/lib/imessage.ts | 74 +++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-imessage.ts b/packages/opencode/src/cli/cmd/platform-imessage.ts index 233edd17fa63..14fb90c86c99 100644 --- a/packages/opencode/src/cli/cmd/platform-imessage.ts +++ b/packages/opencode/src/cli/cmd/platform-imessage.ts @@ -3,7 +3,7 @@ import * as prompts from "@clack/prompts" import { UI } from "../ui" import { printDivider, dim, bold, success } from "./iris-api" import { execSync } from "child_process" -import { isAvailable, query as queryMessages, normalizeHandle } from "../lib/imessage" +import { isAvailable, query as queryMessages, normalizeHandle, getContactCards } from "../lib/imessage" import { resolveContactName, resolveContactNames } from "../lib/contacts" const ImessageSearchCommand = cmd({ @@ -343,6 +343,63 @@ end tell` }, }) +const ImessageContactsCommand = cmd({ + command: "contacts", + aliases: ["vcards", "cards"], + describe: "list contact cards (vCards) shared via iMessage", + builder: (yargs) => + yargs + .option("days", { type: "number", default: 90, describe: "look back N days" }) + .option("chat", { type: "string", describe: "filter by chat/phone number" }) + .option("limit", { type: "number", default: 20 }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ iMessage Contact Cards") + + if (!isAvailable()) { + prompts.log.error("iMessage not available. Requires macOS + Full Disk Access.") + prompts.outro("Done") + return + } + + const sp = prompts.spinner() + sp.start("Scanning attachments…") + + const cards = getContactCards({ + days: args.days as number, + limit: args.limit as number, + chat: args.chat as string | undefined, + }) + + sp.stop(`${cards.length} contact card(s)`) + + if (args.json) { + console.log(JSON.stringify(cards, null, 2)) + prompts.outro("Done") + return + } + + if (cards.length === 0) { + prompts.log.info("No contact cards found in recent messages") + prompts.outro("Done") + return + } + + printDivider() + for (const card of cards) { + console.log(` ${bold(card.full_name)} ${dim(card.date)}`) + if (card.phones.length > 0) console.log(` ${dim("Phone:")} ${card.phones.join(", ")}`) + if (card.emails.length > 0) console.log(` ${dim("Email:")} ${card.emails.join(", ")}`) + if (card.company) console.log(` ${dim("Org:")} ${card.company}`) + console.log(` ${dim("From:")} ${card.sent_by}`) + console.log() + } + printDivider() + prompts.outro(dim("iris leads create --name \"...\" --phone \"...\" --email \"...\"")) + }, +}) + export const PlatformImessageCommand = cmd({ command: "imessage", aliases: ["sms", "messages"], @@ -353,6 +410,7 @@ export const PlatformImessageCommand = cmd({ .command(ImessageReadCommand) .command(ImessageChatsCommand) .command(ImessageSendCommand) + .command(ImessageContactsCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/lib/imessage.ts b/packages/opencode/src/cli/lib/imessage.ts index d9cebab93acb..e30cf108e557 100644 --- a/packages/opencode/src/cli/lib/imessage.ts +++ b/packages/opencode/src/cli/lib/imessage.ts @@ -141,3 +141,77 @@ export function listChats(days = 30, limit = 50): Chat[] { return [] } } + +// ── Contact Card (vCard) Support (#58893) ── + +export interface ContactCard { + filename: string + full_name: string + phones: string[] + emails: string[] + company?: string + sent_by: string + date: string + raw_vcard: string +} + +/** + * Find contact cards (vCards) shared via iMessage. + * Reads attachment metadata from SQLite + parses the .vcf files. + */ +export function getContactCards(options: { days?: number; limit?: number; chat?: string } = {}): ContactCard[] { + if (!isAvailable()) return [] + const days = options.days ?? 90 + const limit = options.limit ?? 20 + const cutoff = days * 86400 + + try { + let where = `(a.mime_type LIKE '%vcard%' OR a.uti LIKE '%vcard%' OR a.filename LIKE '%.vcf') + AND m.date/1000000000 + 978307200 > unixepoch('now') - ${cutoff}` + if (options.chat) { + where += ` AND c.chat_identifier LIKE '%${options.chat.replace(/'/g, "''")}%'` + } + + const sql = `SELECT a.filename, a.transfer_name, + datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') as msg_date, + c.chat_identifier + FROM attachment a + JOIN message_attachment_join maj ON a.ROWID = maj.attachment_id + JOIN message m ON maj.message_id = m.ROWID + JOIN chat_message_join cmj ON m.ROWID = cmj.message_id + JOIN chat c ON cmj.chat_id = c.ROWID + WHERE ${where} + ORDER BY m.date DESC LIMIT ${limit};`.replace(/\n/g, " ").trim() + + const raw = query(sql) + if (!raw) return [] + + const { readFileSync } = require("fs") + return raw.split("\n").map((line): ContactCard | null => { + const [filepath, transferName, date, chatId] = line.split("|") + if (!filepath) return null + + const fullPath = filepath.replace(/^~/, homedir()) + let rawVcard = "" + try { rawVcard = readFileSync(fullPath, "utf-8") } catch { return null } + + const getName = (vc: string) => vc.match(/^FN:(.+)$/m)?.[1]?.trim() ?? transferName?.replace(".vcf", "") ?? "Unknown" + const getPhones = (vc: string) => [...vc.matchAll(/TEL[^:]*:([+\d() -]+)/gm)].map(m => m[1].replace(/[^+\d]/g, "")) + const getEmails = (vc: string) => [...vc.matchAll(/EMAIL[^:]*:(.+)$/gm)].map(m => m[1].trim()) + const getOrg = (vc: string) => vc.match(/^ORG:(.+)$/m)?.[1]?.trim() + + return { + filename: transferName ?? filepath.split("/").pop() ?? "", + full_name: getName(rawVcard), + phones: getPhones(rawVcard), + emails: getEmails(rawVcard), + company: getOrg(rawVcard), + sent_by: chatId, + date, + raw_vcard: rawVcard, + } + }).filter(Boolean) as ContactCard[] + } catch { + return [] + } +} From 06c33f1ee79bc419201fde84a44290143b108d32 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 11:05:18 -0500 Subject: [PATCH 18/24] fix: production command uses --event-id flag instead of positional arg Yargs positional args in parent commands conflict with subcommand routing. Changed to -e/--event-id option. Usage: iris events production -e 1343 overview Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/cli/cmd/platform-events-production.ts | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-events-production.ts b/packages/opencode/src/cli/cmd/platform-events-production.ts index ec2fdf0210da..c2685d5cb23a 100644 --- a/packages/opencode/src/cli/cmd/platform-events-production.ts +++ b/packages/opencode/src/cli/cmd/platform-events-production.ts @@ -65,11 +65,17 @@ const OverviewCmd = cmd({ describe: "full production dashboard — readiness, runsheet, tickets, staff, budget", builder: (y) => y, async handler(args: any) { - const eventId = args._parentEventId + const eventId = args["event-id"] ?? args._parentEventId ?? args.eventId UI.empty() prompts.intro(`◈ Production: Event #${eventId}`) if (!(await requireAuth())) { prompts.outro("Done"); return } + if (!eventId) { + prompts.log.error("No event ID. Usage: iris events production overview") + prompts.outro("Done") + return + } + const sp = prompts.spinner() sp.start("Loading…") @@ -191,7 +197,7 @@ const RunsheetCmd = cmd({ .option("done", { type: "number", describe: "mark item # as done" }) .option("json", { type: "boolean", default: false }), async handler(args: any) { - const eventId = args._parentEventId + const eventId = args["event-id"] ?? args._parentEventId ?? args.eventId UI.empty() prompts.intro(`◈ Runsheet — Event #${eventId}`) if (!(await requireAuth())) { prompts.outro("Done"); return } @@ -317,7 +323,7 @@ const ChecklistCmd = cmd({ .option("undo", { type: "number", describe: "mark item # as not done" }) .option("json", { type: "boolean", default: false }), async handler(args: any) { - const eventId = args._parentEventId + const eventId = args["event-id"] ?? args._parentEventId ?? args.eventId UI.empty() prompts.intro(`◈ Checklist — Event #${eventId}`) if (!(await requireAuth())) { prompts.outro("Done"); return } @@ -416,7 +422,7 @@ const BudgetCmd = cmd({ .option("add-expense", { type: "string", describe: 'add expense: "PA rental 200 pending"' }) .option("json", { type: "boolean", default: false }), async handler(args: any) { - const eventId = args._parentEventId + const eventId = args["event-id"] ?? args._parentEventId ?? args.eventId UI.empty() prompts.intro(`◈ Budget — Event #${eventId}`) if (!(await requireAuth())) { prompts.outro("Done"); return } @@ -499,14 +505,16 @@ const BudgetCmd = cmd({ // Root — registered as subcommand of `iris events` // ============================================================================ +// Shared event ID — set by parent command, read by subcommands +let _productionEventId = 0 + export const ProductionCommand = cmd({ - command: "production ", + command: "production", aliases: ["prod"], describe: "event production management — runsheet, checklist, budget, overview", - builder: (y) => + builder: (y: any) => y - .positional("event-id", { type: "number", demandOption: true }) - .middleware([(argv: any) => { argv._parentEventId = argv["event-id"] }]) + .option("event-id", { type: "number", alias: "e", demandOption: true, describe: "event ID" }) .command(OverviewCmd) .command(RunsheetCmd) .command(ChecklistCmd) @@ -514,3 +522,8 @@ export const ProductionCommand = cmd({ .demandCommand(1, "specify: overview, runsheet, checklist, budget"), async handler() {}, }) + +// Re-export for subcommand access +export function getProductionEventId(args: any): number { + return args["event-id"] ?? _productionEventId ?? 0 +} From e5140c6b183733afc5fd6894e8be8a7f3c8609a1 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 11:05:32 -0500 Subject: [PATCH 19/24] feat: leads create --emails for multiple email addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primary email stays in email column. Additional emails stored in contact_info.emails[] array. Improves merge detection — matching on any email, not just primary. Usage: iris leads create --name "Drew" --email drew@work.com --emails "drew@gmail.com,drew@other.com" Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/platform-leads.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-leads.ts b/packages/opencode/src/cli/cmd/platform-leads.ts index 5918a30ffdfc..fe4a46c4ccbc 100644 --- a/packages/opencode/src/cli/cmd/platform-leads.ts +++ b/packages/opencode/src/cli/cmd/platform-leads.ts @@ -535,7 +535,8 @@ const LeadsCreateCommand = cmd({ builder: (yargs) => yargs .option("name", { describe: "lead name", type: "string" }) - .option("email", { describe: "email address", type: "string" }) + .option("email", { describe: "primary email address", type: "string" }) + .option("emails", { describe: "additional emails (comma-separated)", type: "string" }) .option("phone", { describe: "phone number", type: "string" }) .option("company", { describe: "company name", type: "string" }) .option("source", { describe: "lead source (e.g. referral, inbound, outreach)", type: "string" }) @@ -596,6 +597,13 @@ const LeadsCreateCommand = cmd({ if (args.company) payload.company = args.company if (args.source) payload.source = args.source if (args.status) payload.status = args.status + // Store additional emails in contact_info.emails array + if (args.emails) { + const extras = String(args.emails).split(",").map((e: string) => e.trim()).filter(Boolean) + if (extras.length > 0) { + payload.contact_info = { emails: extras } + } + } const res = await irisFetch("/api/v1/leads", { method: "POST", @@ -612,6 +620,7 @@ const LeadsCreateCommand = cmd({ printKV("ID", l.id) printKV("Name", l.name) printKV("Email", l.email ?? dim("none")) + if (args.emails) printKV("Alt Emails", String(args.emails)) printKV("Company", l.company ?? dim("none")) printKV("Source", l.source ?? args.source ?? dim("none")) printKV("Status", l.status) From 57556910113dff59b404f5bddf97ecd9b650d816 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 11:23:33 -0500 Subject: [PATCH 20/24] =?UTF-8?q?feat:=20iris=20obs=20dashboard=20?= =?UTF-8?q?=E2=80=94=20open=20production=20UI=20from=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iris obs dashboard 1343 — opens in browser iris obs dashboard 1343 --phone — prints the phone URL (same WiFi) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/platform-obs.ts | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-obs.ts b/packages/opencode/src/cli/cmd/platform-obs.ts index 8cd298d3a42e..1e924da76032 100644 --- a/packages/opencode/src/cli/cmd/platform-obs.ts +++ b/packages/opencode/src/cli/cmd/platform-obs.ts @@ -292,13 +292,61 @@ const StatusCmd = cmd({ }, }) +// ── dashboard ── + +const DashboardCmd = cmd({ + command: "dashboard [event-id]", + aliases: ["dash", "ui", "open"], + describe: "open the production dashboard in your browser", + builder: (y) => + y + .positional("event-id", { type: "number", describe: "event ID for timeline" }) + .option("phone", { type: "boolean", default: false, describe: "show the phone URL instead" }), + async handler(args) { + const eventId = args["event-id"] + const baseUrl = `${BRIDGE}/obs-dashboard` + const url = eventId ? `${baseUrl}?event=${eventId}` : baseUrl + + if (args.phone) { + // Get local IP for phone access + try { + const { networkInterfaces } = await import("os") + const nets = networkInterfaces() + for (const name of Object.keys(nets)) { + for (const net of nets[name] ?? []) { + if (net.family === "IPv4" && !net.internal) { + const phoneUrl = `http://${net.address}:3200/obs-dashboard${eventId ? `?event=${eventId}` : ""}` + console.log() + console.log(` ${bold("Phone URL:")} ${highlight(phoneUrl)}`) + console.log(` ${dim("Open this on your phone (same WiFi)")}`) + console.log() + return + } + } + } + } catch {} + console.log(` ${dim("Could not detect local IP")}`) + return + } + + // Open in browser + try { + const { exec } = await import("child_process") + exec(`open "${url}"`) + console.log(` ${success("✓")} Dashboard opened: ${dim(url)}`) + } catch { + console.log(` ${bold("URL:")} ${highlight(url)}`) + } + }, +}) + // ============================================================================ // Root // ============================================================================ export const PlatformObsCommand = cmd({ command: "obs", - describe: "control OBS Studio — scenes, streaming, recording, markers, audio", + describe: "control OBS Studio — scenes, streaming, recording, markers, audio, dashboard", builder: (y) => y .command(ConnectCmd) @@ -311,6 +359,7 @@ export const PlatformObsCommand = cmd({ .command(MuteCmd) .command(InputsCmd) .command(StatusCmd) - .demandCommand(1, "specify: connect, scenes, scene, stream, record, marker, mute, inputs, status"), + .command(DashboardCmd) + .demandCommand(1, "specify: connect, scenes, scene, stream, record, marker, mute, inputs, status, dashboard"), async handler() {}, }) From a2cb2319516bb5def54fcc13cfe3b5d1b69f4a71 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 11:29:17 -0500 Subject: [PATCH 21/24] feat: iris obs dashboard auto-detects ngrok, LAN IP, and localhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One command shows all URLs: - iris obs dashboard 1343 — opens browser + shows local/phone/public URLs - iris obs dashboard 1343 --phone — LAN URL for same-WiFi devices - iris obs dashboard 1343 --public — ngrok public URL (auto-starts if needed) - iris obs dashboard 1343 --share "+15125551234" — sends URL via iMessage Auto-starts ngrok tunnel if not running and ngrok is installed. Detects public URL from ngrok API at localhost:4040. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/platform-obs.ts | 134 ++++++++++++++---- 1 file changed, 110 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-obs.ts b/packages/opencode/src/cli/cmd/platform-obs.ts index 1e924da76032..0251d2a26428 100644 --- a/packages/opencode/src/cli/cmd/platform-obs.ts +++ b/packages/opencode/src/cli/cmd/platform-obs.ts @@ -301,42 +301,128 @@ const DashboardCmd = cmd({ builder: (y) => y .positional("event-id", { type: "number", describe: "event ID for timeline" }) - .option("phone", { type: "boolean", default: false, describe: "show the phone URL instead" }), + .option("phone", { type: "boolean", default: false, describe: "show the local network URL (same WiFi)" }) + .option("public", { type: "boolean", default: false, describe: "show the public ngrok URL (works anywhere)" }) + .option("share", { type: "string", describe: "send the URL to a phone number or email via iMessage" }), async handler(args) { const eventId = args["event-id"] - const baseUrl = `${BRIDGE}/obs-dashboard` - const url = eventId ? `${baseUrl}?event=${eventId}` : baseUrl + const qs = eventId ? `?event=${eventId}` : "" + // Detect all available URLs + const urls: { local: string; phone?: string; public?: string } = { + local: `${BRIDGE}/obs-dashboard${qs}`, + } + + // Local network IP + try { + const { networkInterfaces } = await import("os") + const nets = networkInterfaces() + for (const name of Object.keys(nets)) { + for (const net of nets[name] ?? []) { + if (net.family === "IPv4" && !net.internal) { + urls.phone = `http://${net.address}:3200/obs-dashboard${qs}` + break + } + } + if (urls.phone) break + } + } catch {} + + // Ngrok public URL + try { + const res = await fetch("http://localhost:4040/api/tunnels", { signal: AbortSignal.timeout(2000) }) + if (res.ok) { + const data = (await res.json()) as any + const tunnel = (data.tunnels ?? []).find((t: any) => t.public_url?.startsWith("https")) + if (tunnel) { + urls.public = `${tunnel.public_url}/obs-dashboard${qs}` + } + } + } catch {} + + // --public: just print the public URL + if (args.public) { + if (urls.public) { + console.log() + console.log(` ${bold("Public URL:")} ${highlight(urls.public)}`) + console.log(` ${dim("Works from anywhere — share with anyone")}`) + } else { + console.log(` ${dim("No ngrok tunnel detected. Start one:")} ngrok http 3200`) + } + console.log() + return + } + + // --phone: just print the LAN URL if (args.phone) { - // Get local IP for phone access + if (urls.phone) { + console.log() + console.log(` ${bold("Phone URL:")} ${highlight(urls.phone)}`) + console.log(` ${dim("Open on your phone (same WiFi)")}`) + } else { + console.log(` ${dim("Could not detect local IP")}`) + } + console.log() + return + } + + // --share: send via iMessage + if (args.share) { + const shareUrl = urls.public || urls.phone || urls.local + try { + const { execSync } = await import("child_process") + const handle = String(args.share) + const msg = `🎬 Stream Control Dashboard — open this link:\n\n${shareUrl}\n\nTap scenes to switch cameras. Timeline tab for run-of-show.` + execSync(`osascript -e 'tell application "Messages" to send "${msg.replace(/"/g, '\\"')}" to participant "${handle}" of (1st account whose service type = iMessage)'`, { timeout: 10000 }) + console.log(` ${success("✓")} Sent to ${handle}`) + } catch (e: any) { + console.log(` ${dim("Failed to send:")} ${e.message?.slice(0, 80)}`) + console.log(` ${bold("URL:")} ${highlight(shareUrl)}`) + } + return + } + + // Auto-start ngrok if not running and ngrok is installed + if (!urls.public) { try { - const { networkInterfaces } = await import("os") - const nets = networkInterfaces() - for (const name of Object.keys(nets)) { - for (const net of nets[name] ?? []) { - if (net.family === "IPv4" && !net.internal) { - const phoneUrl = `http://${net.address}:3200/obs-dashboard${eventId ? `?event=${eventId}` : ""}` - console.log() - console.log(` ${bold("Phone URL:")} ${highlight(phoneUrl)}`) - console.log(` ${dim("Open this on your phone (same WiFi)")}`) - console.log() - return - } + const { execSync, spawn } = await import("child_process") + const ngrokPath = execSync("which ngrok", { encoding: "utf-8" }).trim() + if (ngrokPath) { + const sp2 = prompts.spinner() + sp2.start("Starting ngrok tunnel…") + spawn(ngrokPath, ["http", "3200"], { detached: true, stdio: "ignore" }).unref() + // Wait for tunnel to come up + for (let i = 0; i < 10; i++) { + await new Promise(r => setTimeout(r, 1000)) + try { + const res = await fetch("http://localhost:4040/api/tunnels", { signal: AbortSignal.timeout(1000) }) + if (res.ok) { + const data = (await res.json()) as any + const tunnel = (data.tunnels ?? []).find((t: any) => t.public_url?.startsWith("https")) + if (tunnel) { + urls.public = `${tunnel.public_url}/obs-dashboard${qs}` + break + } + } + } catch {} } + sp2.stop(urls.public ? success("Tunnel ready") : "Tunnel failed") } } catch {} - console.log(` ${dim("Could not detect local IP")}`) - return } - // Open in browser + // Show all URLs + console.log() + console.log(` ${bold("Local:")} ${dim(urls.local)}`) + if (urls.phone) console.log(` ${bold("Phone:")} ${highlight(urls.phone)} ${dim("(same WiFi)")}`) + if (urls.public) console.log(` ${bold("Public:")} ${success(urls.public)} ${dim("(works anywhere)")}`) + console.log() + try { const { exec } = await import("child_process") - exec(`open "${url}"`) - console.log(` ${success("✓")} Dashboard opened: ${dim(url)}`) - } catch { - console.log(` ${bold("URL:")} ${highlight(url)}`) - } + exec(`open "${urls.local}"`) + console.log(` ${success("✓")} Opened in browser`) + } catch {} }, }) From f6618773c2b17577f4a173bb642d80de4eb286d2 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 18 Apr 2026 11:30:05 -0500 Subject: [PATCH 22/24] fix: dashboard opens public ngrok URL instead of localhost --- packages/opencode/src/cli/cmd/platform-obs.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-obs.ts b/packages/opencode/src/cli/cmd/platform-obs.ts index 0251d2a26428..ebd096181a9a 100644 --- a/packages/opencode/src/cli/cmd/platform-obs.ts +++ b/packages/opencode/src/cli/cmd/platform-obs.ts @@ -418,9 +418,11 @@ const DashboardCmd = cmd({ if (urls.public) console.log(` ${bold("Public:")} ${success(urls.public)} ${dim("(works anywhere)")}`) console.log() + // Open best available URL — prefer public > phone > local + const openUrl = urls.public || urls.phone || urls.local try { const { exec } = await import("child_process") - exec(`open "${urls.local}"`) + exec(`open "${openUrl}"`) console.log(` ${success("✓")} Opened in browser`) } catch {} }, From 52ec42bf02adedb6405de214553a4e63c6fe2b89 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 19 Apr 2026 09:48:22 -0500 Subject: [PATCH 23/24] fix: bloqs create/get/ingest use wrong API route + silent error display (#59970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All bloqs subcommands used `/api/v1/users/` (plural) but the Laravel route is `/api/v1/user/` (singular), causing 404s that appeared as silent "Failed" with no error detail. - Fix URL path from `users` → `user` across all 6 bloqs subcommands - Stop spinner before calling handleApiError so error messages render visibly - Parse create response correctly (data.bloq, not data directly) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/platform-bloqs.ts | 72 ++++++++++++------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index aa134ccd328c..2f972e2773bc 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -47,8 +47,12 @@ const BloqsListCommand = cmd({ try { const params = new URLSearchParams({ per_page: String(args.limit) }) const res = await irisFetch(`/api/v1/user/${userId}/bloqs?${params}`) - const ok = await handleApiError(res, "List bloqs") - if (!ok) { spinner.stop("Failed", 1); process.exitCode = 1; prompts.outro("Done"); return } + if (!res.ok) { + spinner.stop("Failed", 1) + await handleApiError(res, "List bloqs") + prompts.outro("Done") + return + } const data = (await res.json()) as { data?: any[] } const bloqs: any[] = data?.data ?? [] @@ -105,9 +109,13 @@ const BloqsGetCommand = cmd({ spinner.start("Loading…") try { - const res = await irisFetch(`/api/v1/users/${userId}/bloqs/${args.id}`) - const ok = await handleApiError(res, "Get bloq") - if (!ok) { spinner.stop("Failed", 1); process.exitCode = 1; prompts.outro("Done"); return } + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args.id}`) + if (!res.ok) { + spinner.stop("Failed", 1) + await handleApiError(res, "Get bloq") + prompts.outro("Done") + return + } const data = (await res.json()) as { data?: any } const b = data?.data ?? data @@ -122,7 +130,7 @@ const BloqsGetCommand = cmd({ console.log() // Load lists - const listsRes = await irisFetch(`/api/v1/users/${userId}/bloqs/${args.id}/lists`) + const listsRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${args.id}/lists`) if (listsRes.ok) { const listsData = (await listsRes.json()) as { data?: any[] } const lists: any[] = listsData?.data ?? [] @@ -137,7 +145,7 @@ const BloqsGetCommand = cmd({ // Load files if requested if (args.files) { - const filesRes = await irisFetch(`/api/v1/users/${userId}/bloqs/${args.id}/files`) + const filesRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${args.id}/files`) if (filesRes.ok) { const filesData = (await filesRes.json()) as { data?: any[] } const files: any[] = filesData?.data ?? [] @@ -228,15 +236,19 @@ const BloqsCreateCommand = cmd({ spinner.start("Creating bloq…") try { - const res = await irisFetch(`/api/v1/users/${userId}/bloqs`, { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs`, { method: "POST", body: JSON.stringify({ name, description }), }) - const ok = await handleApiError(res, "Create bloq") - if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + if (!res.ok) { + spinner.stop("Failed", 1) + await handleApiError(res, "Create bloq") + prompts.outro("Done") + return + } - const data = (await res.json()) as { data?: any } - const b = data?.data ?? data + const data = (await res.json()) as { data?: { bloq?: any } } + const b = data?.data?.bloq ?? data?.data ?? data spinner.stop(`${success("✓")} Bloq created: ${bold(String(b.name ?? b.id))}`) printDivider() @@ -292,14 +304,18 @@ const BloqsIngestCommand = cmd({ const formData = new FormData() formData.append("file", new Blob([blob]), filename) - const res = await fetch(`${FL_API}/api/v1/users/${userId}/bloqs/${args.id}/files`, { + const res = await fetch(`${FL_API}/api/v1/user/${userId}/bloqs/${args.id}/files`, { method: "POST", headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, body: formData, }) - const ok = await handleApiError(res, "Ingest file") - if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + if (!res.ok) { + spinner.stop("Failed", 1) + await handleApiError(res, "Ingest file") + prompts.outro("Done") + return + } const data = (await res.json()) as { data?: any; message?: string } spinner.stop(`${success("✓")} ${filename} ingested`) @@ -380,11 +396,15 @@ const BloqsAddItemCommand = cmd({ if (title) payload.title = title const res = await irisFetch( - `/api/v1/users/${userId}/bloqs/${args["bloq-id"]}/lists/${args["list-id"]}/items`, + `/api/v1/user/${userId}/bloqs/${args["bloq-id"]}/lists/${args["list-id"]}/items`, { method: "POST", body: JSON.stringify(payload) }, ) - const ok = await handleApiError(res, "Add item") - if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + if (!res.ok) { + spinner.stop("Failed", 1) + await handleApiError(res, "Add item") + prompts.outro("Done") + return + } spinner.stop(`${success("✓")} Item added`) prompts.outro(dim(`iris bloqs get ${args["bloq-id"]}`)) @@ -480,21 +500,25 @@ const BloqsComposeCommand = cmd({ spinner.start("Creating knowledge base…") try { - const res = await irisFetch(`/api/v1/users/${userId}/bloqs`, { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs`, { method: "POST", body: JSON.stringify({ name, description }), }) - const ok = await handleApiError(res, "Create bloq") - if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + if (!res.ok) { + spinner.stop("Failed", 1) + await handleApiError(res, "Create bloq") + prompts.outro("Done") + return + } - const data = (await res.json()) as { data?: any } - const bloq = data?.data ?? data + const data = (await res.json()) as { data?: { bloq?: any } } + const bloq = data?.data?.bloq ?? data?.data ?? data const bloqId = bloq.id // Step 5: Create lists let listsCreated = 0 for (const listName of suggestedLists) { - const listRes = await irisFetch(`/api/v1/users/${userId}/bloqs/${bloqId}/lists`, { + const listRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}/lists`, { method: "POST", body: JSON.stringify({ name: listName }), }) From c018b22aba223e7fb03f93b1b1baac5ba1de0b59 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 19 Apr 2026 09:49:21 -0500 Subject: [PATCH 24/24] chore: bump version to 1.2.1 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 24c69f0b90ba..b143141f465a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.25", + "version": "1.2.1", "name": "opencode", "displayName": "iris-agent-cli", "type": "module",