IRIS CLI v1.2.1 — Fix bloqs create silent failure - #3
Conversation
…ime (#58778) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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 <name> — 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 <input> — 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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…787) 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) <noreply@anthropic.com>
…3/#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) <noreply@anthropic.com>
…8785) 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…(#58884) 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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…tacts libs 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
New subcommand group for event production management: - iris events production <id> overview — full production dashboard - iris events production <id> runsheet — timeline with NOW/NEXT, --add, --done - iris events production <id> checklist — todo list with completion %, --add, --done - iris events production <id> 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) <noreply@anthropic.com>
… (#58893) 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ay (#59970) 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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR aims to fix IRIS CLI Bloqs commands that were failing due to incorrect API routing and poor error visibility, but it also introduces several new CLI features (OBS control, iMessage utilities, and Events production checks) and accompanying regression tests.
Changes:
- Fix Bloqs create/get/ingest endpoints to use
/api/v1/user/{id}/...and ensure API errors are shown after stopping spinners. - Add shared iMessage + contact resolution libraries and expand iMessage CLI capabilities (search improvements, chat name resolution, vCard extraction).
- Add new operational tooling (OBS CLI + Events preflight/audit/production commands) and new regression test suites.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/opencode/src/cli/cmd/platform-bloqs.ts | Fix Bloqs API routes and stop spinner before rendering API error details. |
| packages/opencode/package.json | Bump CLI version to 1.2.1. |
| packages/opencode/src/cli/cmd/platform-run.ts | Enhance array item display (calendar summary + start time) and add calendar integration functions. |
| packages/opencode/test/cli/bug-fixes.test.ts | Add tests for calendar event label + start time parsing logic. |
| packages/opencode/test/platform/bug-regressions.test.ts | Add broad regression suite asserting fixes across multiple platform commands. |
| packages/opencode/src/cli/lib/imessage.ts | New shared sqlite-based iMessage access utilities + vCard extraction. |
| packages/opencode/src/cli/lib/contacts.ts | New shared lead-name resolution + caching for phone/email identifiers. |
| packages/opencode/src/cli/cmd/platform-imessage.ts | Refactor to shared libs; add --since, lead resolution, chat labeling, and contacts subcommand. |
| packages/opencode/src/cli/cmd/platform-leads.ts | Add alt-email support in lead create; reuse iMessage availability checker in health checks. |
| packages/opencode/src/cli/cmd/platform-doctor.ts | Reuse shared iMessage availability checker for Full Disk Access check. |
| packages/opencode/src/cli/cmd/platform-customer.ts | Switch iMessage DB querying to shared lib. |
| packages/opencode/src/cli/cmd/platform-atlas-comms.ts | Switch iMessage ingestion to shared lib helpers. |
| packages/opencode/src/cli/cmd/platform-obs.ts | New OBS control command suite (bridge-based) including dashboard share/open helpers. |
| packages/opencode/src/index.ts | Register new obs command in the CLI. |
| packages/opencode/src/cli/cmd/command-groups.ts | Categorize obs command for grouped help. |
| packages/opencode/src/cli/cmd/platform-events.ts | Expand Events tooling: push metadata pass-through, tickets push --force, preflight/audit commands, and production subcommand registration. |
| packages/opencode/src/cli/cmd/platform-events-production.ts | New production subcommands (overview/runsheet/checklist/budget) stored in event metadata. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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}` |
There was a problem hiding this comment.
searchByHandle() interpolates search directly into the SQL LIKE '%${search}%' without escaping single quotes. If handle is an email/chat identifier containing ', the query will break (and could be leveraged for SQL injection inside sqlite). Escape ' as '' (as you already do elsewhere) before building the SQL, or parameterize the query.
| // Resolve contact name from leads (#58888) | ||
| const contactName = await resolveContactName(digits || String(args.query)) ?? "Them" |
There was a problem hiding this comment.
contactName resolution uses digits || args.query, but digits is derived from the original query. If the query is a lead name that gets resolved to a phone/email (lines 35-58), digits will still be empty and you'll end up resolving by the name string instead of the resolved identifier. Track the resolved identifier (e.g. resolved phone/email) and pass that into resolveContactName(...) so the displayed sender label matches the chat handle actually queried.
| // Resolve contact name from leads (#58888) | |
| const contactName = await resolveContactName(digits || String(args.query)) ?? "Them" | |
| // Resolve contact name from the actual identifier used for the query (#58888) | |
| const contactName = await resolveContactName(resolvedIdentifier) ?? "Them" |
| 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}`) |
There was a problem hiding this comment.
The --share path builds an osascript command string using execSync(...) and interpolates both handle (user input) and msg into a single-quoted shell command. This is vulnerable to shell/AppleScript injection (e.g. a handle containing ' breaks quoting) and can also fail on normal characters. Use execFileSync("osascript", ["-e", script]) (no shell) and escape values for AppleScript (or pass them as arguments) rather than concatenating into the command string.
| // iMessage — verify macOS Messages.app SQLite access | ||
| (async (): Promise<ChannelHealth> => { | ||
| 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" } |
There was a problem hiding this comment.
The iMessage health check treats any isAvailable() === false as status: "no_permission" with a Full Disk Access hint. isAvailable() also returns false on non-macOS and when the DB file doesn't exist, so this will misreport the failure mode (e.g. Linux users will be told to enable Full Disk Access). Consider branching on process.platform !== "darwin" / missing DB separately (e.g. status: "error" with "macOS only" / "Messages DB not found") and reserve no_permission for actual permission errors.
|
|
||
| // ============================================================================ | ||
| // Event Production CLI — runsheet, checklist, budget, overview | ||
| // All data in metadata.production (no new tables) |
There was a problem hiding this comment.
File header says “All data in metadata.production”, but the implementation also reads event.metadata.production_timeline / local.data.metadata.production_timeline as a separate top-level metadata field. Either move that timeline under metadata.production (to match the statement) or update the comment so it accurately describes where production data lives.
| // All data in metadata.production (no new tables) | |
| // Production data lives in metadata.production; timeline data may also be read | |
| // from metadata.production_timeline (no new tables) |
| 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() | ||
| } |
There was a problem hiding this comment.
isAvailable()/query() use execSync() with a shell command string (e.g. sqlite3 "${MESSAGES_DB}" "${escaped}"). Because the SQL text can include user-derived values in callers, shell expansions like $(...)/backticks can be evaluated before sqlite3 runs, creating a command-injection risk and also making quoting brittle. Use execFileSync/spawnSync with an argument array (no shell) and pass the SQL as a separate argv element or via stdin; also consider rejecting/escaping characters like $ and backticks if any user input can reach SQL.
| builder: (yargs) => | ||
| yargs | ||
| .positional("query", { type: "string", demandOption: true, describe: "phone number or chat identifier" }) | ||
| .option("last", { type: "number", default: 10, describe: "number of recent messages" }) | ||
| .option("days", { type: "number", default: 30, describe: "search last N days" }) | ||
| .option("json", { type: "boolean", default: false }), | ||
| async handler(args) { | ||
| 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, "''")}%'` | ||
|
|
||
| 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 |
There was a problem hiding this comment.
ImessageReadCommand uses args.since to compute cutoffSeconds, but the command builder never defines a --since option. As written, --since won't be recognized by yargs and the new logic is effectively dead code. Add the .option("since", ...) to the builder (matching search/chats) or remove the args.since branch here.
| const { exec } = await import("child_process") | ||
| exec(`open "${openUrl}"`) |
There was a problem hiding this comment.
exec(open "${openUrl}") executes through a shell and openUrl is derived from network sources (ngrok API) and local IP detection. If the URL ever contains quotes or shell metacharacters, this can lead to command injection or just broken behavior. Prefer execFile/spawn with args (e.g. open, [openUrl]) so the URL is passed as a single argument.
| const { exec } = await import("child_process") | |
| exec(`open "${openUrl}"`) | |
| const { spawn } = await import("child_process") | |
| spawn("open", [openUrl], { detached: true, stdio: "ignore" }).unref() |
| const whereClause = isPhone | ||
| ? `c.chat_identifier LIKE '%${digits.slice(-10)}%'` | ||
| ? `c.chat_identifier LIKE '%${normalized}%'` | ||
| : `c.chat_identifier LIKE '%${args.query.replace(/'/g, "''")}%'` |
There was a problem hiding this comment.
In search, when a non-phone query resolves to a lead email (lines 54-58), normalized is updated but whereClause still uses args.query in the non-phone branch. That means the email resolution has no effect on the actual SQLite query (it still searches for the original name string). Use the resolved identifier (normalized) in the LIKE clause for the non-phone case as well (with proper SQL escaping), or introduce a separate searchHandle variable that always reflects what you’re querying for.
| : `c.chat_identifier LIKE '%${args.query.replace(/'/g, "''")}%'` | |
| : `c.chat_identifier LIKE '%${normalized.replace(/'/g, "''")}%'` |
| const cutoffSeconds = args.since | ||
| ? Math.max(0, Math.floor((Date.now() - new Date(String(args.since)).getTime()) / 1000)) | ||
| : (args.days as number) * 86400 |
There was a problem hiding this comment.
--since is parsed via new Date(String(args.since)).getTime() without validating the result. If the user passes an invalid date (or a timezone-less string that parses unexpectedly), getTime() becomes NaN and you’ll emit unixepoch('now') - NaN in SQL, which fails with a confusing sqlite error. Validate Number.isFinite(ts) and surface a clear error (and non-zero exit code) when --since can’t be parsed.
| const cutoffSeconds = args.since | |
| ? Math.max(0, Math.floor((Date.now() - new Date(String(args.since)).getTime()) / 1000)) | |
| : (args.days as number) * 86400 | |
| let cutoffSeconds = (args.days as number) * 86400 | |
| if (args.since) { | |
| const ts = new Date(String(args.since)).getTime() | |
| if (!Number.isFinite(ts)) { | |
| prompts.log.error(`Invalid value for --since: "${String(args.since)}". Please use a valid date/time.`) | |
| process.exitCode = 1 | |
| prompts.outro("Done") | |
| return | |
| } | |
| cutoffSeconds = Math.max(0, Math.floor((Date.now() - ts) / 1000)) | |
| } |
…ed on a 404 `iris integrations list` collapsed every probe failure into "unverified — could not probe", which tells the reader nothing and cost real debugging hours. Two worse bugs surfaced while fixing that: 1. The gmail and google-drive probes returned "verified" for ANY response that was not 401/403 — including 404 and 500. Both probe endpoints currently 404 (/api/v1/leads/0/gmail-threads, /api/v1/integrations/exec), so Gmail has been rendering a green [verified] while its probe does not resolve at all. The health signal on those rows was meaningless. 2. Probe results were keyed by integration TYPE, not id, and the drive probe never passed integration_id. All three google-drive accounts (#4, #11, #12) therefore shared one result taken from whichever connection the API picks by default — two accounts could be dead and every row would show the third's status. The comment directly above that code says multi-account visibility is the point of the command. Changes: - Probes return { state, reason, fix } instead of a bare string, so every row states its cause and, where one exists, the command that fixes it. - New "unknown" state, distinct from failure. A 404 on the PROBE path means we cannot determine the integration's health; claiming either verified or unverified would be unsupported. Rendered neutral rather than red, because the fix belongs to us, not the user. - Network failures are classified — connection refused / timeout / DNS — rather than collapsing into one string. - Results keyed by integration id; the drive probe passes integration_id so each account is genuinely probed. - The calendar probe distinguishes bridge-not-running, no-bridge-key, key-rejected and HTTP-n, each with `iris hive doctor` as the fix. before gmail #3 [verified] after gmail #3 [unknown] — probe endpoint returned 404; status not determined before google-calendar #2 [unverified] — could not probe after google-calendar #2 [unverified] — no bridge key configured → iris hive doctor Verified by running the command from source, not by reading the diff. Typecheck clean; the remaining tsc error (session/llm.ts:88 TS2589) is pre-existing and reproduces with this change stashed. Refs #180929, #181016 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ust that it failed (#182862) The ticket says --account "fails to resolve existing connections". It does not. Reproduced on the reporting account: alex@freelabel.net is a GOOGLE-DRIVE account (#4) the two GMAIL connections are #3 (no account_email at all) and #14 (admin@vanguardhcs.com) So there is genuinely no gmail connection at that address, and the resolution was correct. The defect is the sentence it produced: No connection for type='gmail' matching --account='alex@freelabel.net'. Run: iris integrations list The user runs that, sees Gmail plainly connected, and concludes the flag is broken. The message described the outcome instead of the evidence, and sent them somewhere that contradicted it. It now lists the connections of that type, with their ids and addresses, and says which of them can NEVER match because no account_email is stored — 21 of 25 connections on this account carry none, so an email match against them cannot succeed and no amount of retrying will help. The fix is --integration-id or a reconnect, and neither was guessable before. "Nothing matched" and "nothing is connected" are now different sentences with different fixes. ALSO, one layer down: `if (!res.ok) return null` meant a 500 on OUR lookup endpoint rendered as "no connection" — the same conflation as #182861, where a failure in our path became a claim about the user's integration. That now says the lookup failed and that it implies nothing about whether the integration is connected. describeAccountMiss is extracted and exported because the bug was in the sentence, so the sentence is the thing worth pinning. 5 tests, no transport needed. Typechecked: zero diagnostics in platform-run.ts (TS2589 in session/llm.ts is pre-existing). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDsGZrjZEh5f2GTtZj9oBK
`OutreachScriptTemplate` is referenced by every outreach step (`script_template_id`) and
stamped on every comm for attribution, has server-side versioning with activate and
rollback, and is fully routed — 15 endpoints under
/api/v1/bloqs/{bloq}/outreach-script-templates including a whole /versions subtree.
None of it was reachable. The only way to touch a script was `iris som edit <campaign>`
inline, which meant scripts could only be edited through the campaign path even though
strategies depend on them equally, and the version history could never be listed, snapshotted
or rolled back from the CLI at all.
iris reachr script list|show|create|update|delete <bloq> [id]
iris reachr script versions|snapshot|activate <bloq> <id> (activate aliases rollback)
Two things found by running it rather than reading it:
- `snapshot` posting only a label got a 500 "Failed to add version". The endpoint requires
`message` and sets the live text while snapshotting it, so "snapshot the current message"
means reading it first and sending it back unchanged. The server laundering a validation
error into a generic 500 is filed separately.
- `activate <bloq> <id> <version>` always sent version 0. A positional named `version` is
shadowed by the CLI's global `-v, --version`, so args.version was the flag, not the
argument, and the server answered "Version 0 not found" — an error that names the symptom
and nothing about where the 0 came from. The positional is `<n>`.
Also warns locally on merge tokens nothing will substitute, before the request goes out.
{sender_name} shipped to a real inbox last week because that check existed nowhere.
Verified against the live API: created script #3 on board 657, snapshotted v1, edited,
snapshotted v2, rolled back to v1 and confirmed the original text returned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDsGZrjZEh5f2GTtZj9oBK
Summary
/users/→/user/), causing 404s that appeared as silent "Failed" with no error detaildata.bloqnotdatadirectly)Fixes #59970 — reported by external user (Patsy Keegan)
Test plan
iris bloqs create --name "Test"— creates successfully, shows ID and nameiris bloqs list— lists bloqs correctly🤖 Generated with Claude Code